From e6e247236126258f2145a9671f53478d0dad3129 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sat, 4 Jul 2026 16:58:12 -0400 Subject: [PATCH 01/93] Add parser for CDISC CORE JSON reports --- pointblank/metadata/_cdisc_core.py | 304 +++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 pointblank/metadata/_cdisc_core.py diff --git a/pointblank/metadata/_cdisc_core.py b/pointblank/metadata/_cdisc_core.py new file mode 100644 index 000000000..5d8f52272 --- /dev/null +++ b/pointblank/metadata/_cdisc_core.py @@ -0,0 +1,304 @@ +"""Parser for CDISC CORE engine JSON reports (PLAN_06 Phase 2, Path A). + +Pointblank wraps the open-source CDISC CORE engine (`cdisc-rules-engine`) as an external process: +datasets and Define-XML are handed to CORE, it runs the authoritative conformance rule set, and its +JSON report is parsed back into Pointblank's [`ConformanceReport`](`pointblank.ConformanceReport`). + +This module implements the *parsing* half — turning CORE's JSON report into typed objects. The +subprocess runner and dataset materialization live elsewhere. The parser is written against the +JSON schema emitted by `core validate -of JSON` (verified against CORE 0.16.0): + +- `Conformance_Details` — run provenance (standard, version, CT version, engine version, runtime). +- `Dataset_Details` — one entry per validated dataset (filename, label, path, size, row count). +- `Issue_Summary` — one entry per (dataset, rule) that reported issues, with a count. +- `Issue_Details` — row-level findings (rule id, message, dataset, USUBJID, row, variables, values). +- `Rules_Report` — the run status of every rule (`SKIPPED` / `SUCCESS` / `ISSUE REPORTED` / + `EXECUTION ERROR`). + +Note: the CORE JSON report carries **no severity field** (no Reject/Error/Warning/Notice). Pass/fail +gating therefore keys off rule *status*, not severity. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field as dataclass_field +from typing import Any + +__all__ = [ + "CoreFinding", + "CoreRuleResult", + "CoreIssueSummary", + "ParsedCoreReport", + "parse_core_report", + "STATUS_SUCCESS", + "STATUS_SKIPPED", + "STATUS_ISSUE", + "STATUS_ERROR", +] + +# Rule run-status values emitted by CORE in the `Rules_Report` section. +STATUS_SUCCESS = "SUCCESS" # rule ran, data conformed +STATUS_SKIPPED = "SKIPPED" # rule not applicable / required data absent +STATUS_ISSUE = "ISSUE REPORTED" # rule ran, conformance issue(s) found +STATUS_ERROR = "EXECUTION ERROR" # rule failed to execute + +# Statuses that constitute a conformance failure (used for pass/fail gating). +_FAILING_STATUSES = frozenset({STATUS_ISSUE, STATUS_ERROR}) + + +@dataclass +class CoreFinding: + """A single row-level conformance finding from CORE's `Issue_Details`. + + Parameters + ---------- + rule_id + The CORE rule identifier (e.g., `"CORE-000357"`). + message + The human-readable rule message describing the issue. + dataset + The dataset (domain) the finding was raised against. + executability + CORE's executability note for the rule (e.g., `"fully executable"`). + usubjid + The `USUBJID` of the offending record, if applicable (may be empty). + row + The 1-based row number of the offending record, if applicable. + seq + The `--SEQ` value of the offending record, if applicable (may be empty). + variables + The variable name(s) implicated in the finding. + values + The offending value(s) corresponding to `variables`. + """ + + rule_id: str + message: str + dataset: str + executability: str | None = None + usubjid: str | None = None + row: int | None = None + seq: str | None = None + variables: list[Any] = dataclass_field(default_factory=list) + values: list[Any] = dataclass_field(default_factory=list) + + +@dataclass +class CoreIssueSummary: + """A per-(dataset, rule) issue count from CORE's `Issue_Summary`. + + Parameters + ---------- + dataset + The dataset the issues were raised against (or `"STUDY"` for study-level issues). + rule_id + The CORE rule identifier. + message + The rule message. + issues + The number of issues reported for this (dataset, rule) pair. + """ + + dataset: str + rule_id: str + message: str + issues: int + + +@dataclass +class CoreRuleResult: + """The run status of a single rule from CORE's `Rules_Report`. + + Parameters + ---------- + rule_id + The CORE rule identifier (e.g., `"CORE-000001"`). + status + The run status: `"SUCCESS"`, `"SKIPPED"`, `"ISSUE REPORTED"`, or `"EXECUTION ERROR"`. + message + The rule message. + version + The rule version. + cdisc_rule_id + The corresponding CDISC rule identifier(s) (e.g., `"CG0176, TIG0405"`). + fda_rule_id + The corresponding FDA rule identifier(s), if any. + """ + + rule_id: str + status: str + message: str | None = None + version: str | None = None + cdisc_rule_id: str | None = None + fda_rule_id: str | None = None + + @property + def is_failing(self) -> bool: + """Whether this rule's status constitutes a conformance failure.""" + return self.status in _FAILING_STATUSES + + +@dataclass +class ParsedCoreReport: + """A parsed CDISC CORE JSON report. + + Parameters + ---------- + details + The `Conformance_Details` block (run provenance) as a plain dict. + datasets + The `Dataset_Details` entries as plain dicts. + issue_summary + Per-(dataset, rule) issue counts. + findings + Row-level findings. + rules + Per-rule run results. + """ + + details: dict[str, Any] = dataclass_field(default_factory=dict) + datasets: list[dict[str, Any]] = dataclass_field(default_factory=list) + issue_summary: list[CoreIssueSummary] = dataclass_field(default_factory=list) + findings: list[CoreFinding] = dataclass_field(default_factory=list) + rules: list[CoreRuleResult] = dataclass_field(default_factory=list) + + # ── Derived views ──────────────────────────────────────────────────────── + + @property + def standard(self) -> str | None: + """The CDISC standard the run validated against (e.g., `"SDTMIG"`).""" + return self.details.get("Standard") + + @property + def version(self) -> str | None: + """The standard version the run validated against (e.g., `"V3.4"`).""" + return self.details.get("Version") + + @property + def engine_version(self) -> str | None: + """The CORE engine version that produced the report.""" + return self.details.get("CORE_Engine_Version") + + @property + def n_total_issues(self) -> int: + """Total number of issues across all (dataset, rule) pairs.""" + return sum(s.issues for s in self.issue_summary) + + def status_counts(self) -> dict[str, int]: + """Count rules by run status.""" + counts: dict[str, int] = {} + for r in self.rules: + counts[r.status] = counts.get(r.status, 0) + 1 + return counts + + def failing_rules(self) -> list[CoreRuleResult]: + """The rules whose status constitutes a conformance failure.""" + return [r for r in self.rules if r.is_failing] + + @property + def all_passed(self) -> bool: + """Whether the run reported no conformance failures. + + A run passes when no rule has status `ISSUE REPORTED` or `EXECUTION ERROR`. If the report + contains no `Rules_Report` (older/minimal reports), this falls back to the absence of any + entries in `Issue_Summary`. + """ + if self.rules: + return not any(r.is_failing for r in self.rules) + return len(self.issue_summary) == 0 + + +def _as_int(value: Any) -> int | None: + """Coerce a value to int, returning None for empty/unparseable input.""" + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _empty_to_none(value: Any) -> Any: + """Normalize CORE's empty-string sentinels to None.""" + return None if value == "" else value + + +def parse_core_report(report: dict[str, Any]) -> ParsedCoreReport: + """Parse a CDISC CORE JSON report into a [`ParsedCoreReport`](`ParsedCoreReport`). + + Parameters + ---------- + report + The report as a `dict` (from `json.load` of CORE's `-of JSON` output). Both the standard + report and the `--raw-report` variant are accepted; the extra `results_data` key in the raw + variant is ignored. + + Returns + ------- + ParsedCoreReport + The parsed report with typed findings, per-rule results, and run provenance. + + Raises + ------ + TypeError + If `report` is not a dict. + ValueError + If `report` does not look like a CORE report (none of the expected sections present). + """ + if not isinstance(report, dict): + raise TypeError(f"Expected a CORE report dict, got {type(report).__name__}.") + + expected = {"Conformance_Details", "Issue_Details", "Rules_Report", "Issue_Summary"} + if not expected & set(report.keys()): + raise ValueError( + "Input does not look like a CDISC CORE JSON report; expected one of " + f"{sorted(expected)} among top-level keys, got {sorted(report.keys())}." + ) + + details = report.get("Conformance_Details") or {} + datasets = list(report.get("Dataset_Details") or []) + + issue_summary = [ + CoreIssueSummary( + dataset=item.get("dataset", ""), + rule_id=item.get("core_id", ""), + message=item.get("message", ""), + issues=_as_int(item.get("issues")) or 0, + ) + for item in (report.get("Issue_Summary") or []) + ] + + findings = [ + CoreFinding( + rule_id=item.get("core_id", ""), + message=item.get("message", ""), + dataset=item.get("dataset", ""), + executability=_empty_to_none(item.get("executability")), + usubjid=_empty_to_none(item.get("USUBJID")), + row=_as_int(item.get("row")), + seq=_empty_to_none(item.get("SEQ")), + variables=list(item.get("variables") or []), + values=list(item.get("values") or []), + ) + for item in (report.get("Issue_Details") or []) + ] + + rules = [ + CoreRuleResult( + rule_id=item.get("core_id", ""), + status=item.get("status", ""), + message=_empty_to_none(item.get("message")), + version=_empty_to_none(item.get("version")), + cdisc_rule_id=_empty_to_none(item.get("cdisc_rule_id")), + fda_rule_id=_empty_to_none(item.get("fda_rule_id")), + ) + for item in (report.get("Rules_Report") or []) + ] + + return ParsedCoreReport( + details=details, + datasets=datasets, + issue_summary=issue_summary, + findings=findings, + rules=rules, + ) From 129d9217d3b0913898a6699d2361a56c299d7d0c Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sat, 4 Jul 2026 16:59:04 -0400 Subject: [PATCH 02/93] Add submission package conformance model --- pointblank/metadata/_submission.py | 1064 ++++++++++++++++++++++++++++ 1 file changed, 1064 insertions(+) create mode 100644 pointblank/metadata/_submission.py diff --git a/pointblank/metadata/_submission.py b/pointblank/metadata/_submission.py new file mode 100644 index 000000000..39747334c --- /dev/null +++ b/pointblank/metadata/_submission.py @@ -0,0 +1,1064 @@ +"""Submission-package model for CDISC conformance validation. + +This module implements the *submission-package* layer described in PLAN_06: a data-level +analog of [`MetadataPackage`](`pointblank.MetadataPackage`) that understands the relationships +*between* datasets in a study (referential integrity, SUPP-- linkage, RELREC, ADaM ⇄ SDTM +traceability) and drives Pointblank validation across the whole package. + +Where [`validate_sdtm()`](`pointblank.validate_sdtm`) and +[`validate_adam()`](`pointblank.validate_adam`) validate a *single* dataset structurally, the +[`SubmissionPackage`](`pointblank.SubmissionPackage`) validates a study as a graph of related +datasets, adding the cross-dataset checks that today send sponsors to Pinnacle 21 / CDISC CORE. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field as dataclass_field +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pointblank.metadata._cdisc_core import CoreFinding, CoreRuleResult, ParsedCoreReport + from pointblank.metadata._types import MetadataPackage + from pointblank.validate import Validate + +__all__ = [ + "SubmissionPackage", + "ConformanceReport", +] + + +# ── Dataset-name classification helpers ────────────────────────────────────── + + +def _is_supp(name: str) -> bool: + """Whether a dataset name is an SDTM Supplemental Qualifiers (SUPP--) dataset.""" + return name.upper().startswith("SUPP") + + +def _is_relrec(name: str) -> bool: + """Whether a dataset name is the SDTM Related Records (RELREC) dataset.""" + return name.upper() == "RELREC" + + +def _is_adam(name: str) -> bool: + """Whether a dataset name is an ADaM dataset (conventionally prefixed ``AD``).""" + return name.upper().startswith("AD") + + +def _column_names(data: Any) -> list[str]: + """Return the column names of a native table via narwhals.""" + import narwhals as nw + + return list(nw.from_native(data, eager_only=True).columns) + + +def _column_value_set(data: Any, column: str) -> set: + """Return the set of non-null values in a column of a native table.""" + import narwhals as nw + + df = nw.from_native(data, eager_only=True) + if column not in df.columns: + return set() + return {v for v in df[column].to_list() if v is not None} + + +# ── XPT / Dataset-JSON ingestion ───────────────────────────────────────────── + + +def _read_xpt_data(path: Path) -> Any: + """Read a SAS Transport (XPT) file into a pandas DataFrame.""" + try: + import pyreadstat + except ImportError: + raise ImportError( + "The 'pyreadstat' package is required to read XPT files. " + "Install it with: pip install pyreadstat" + ) from None + + df, _meta = pyreadstat.read_xport(str(path)) + return df + + +def _read_dataset_json(path: Path) -> tuple[Any, str | None]: + """Read a CDISC Dataset-JSON file into a pandas DataFrame. + + Supports both the Dataset-JSON 1.1 top-level ``columns``/``rows`` layout and the older + ``clinicalData``/``referenceData`` → ``itemGroupData`` nesting. + + Returns + ------- + tuple[Any, str | None] + The DataFrame and the dataset name (domain) if discoverable, else ``None``. + """ + import json + + try: + import pandas as pd + except ImportError: + raise ImportError( + "The 'pandas' package is required to read Dataset-JSON files. " + "Install it with: pip install pandas" + ) from None + + with open(path) as f: + doc = json.load(f) + + # ── Dataset-JSON 1.1: top-level columns + rows ── + if isinstance(doc, dict) and "columns" in doc and "rows" in doc: + col_names = [c.get("name") for c in doc["columns"]] + df = pd.DataFrame(doc["rows"], columns=col_names) + name = doc.get("name") or doc.get("itemGroupOID") + return df, (str(name).upper() if name else None) + + # ── Older Dataset-JSON: clinicalData / referenceData → itemGroupData ── + for section in ("clinicalData", "referenceData"): + block = doc.get(section) if isinstance(doc, dict) else None + if not block: + continue + item_groups = block.get("itemGroupData", {}) + for oid, group in item_groups.items(): + items = group.get("items", []) + col_names = [it.get("name") for it in items] + rows = group.get("itemData", []) + df = pd.DataFrame(rows, columns=col_names) + name = group.get("name") or oid + # Strip a leading "IG." OID prefix if present + if isinstance(name, str) and name.upper().startswith("IG."): + name = name[3:] + return df, (str(name).upper() if name else None) + + raise ValueError( + f"Could not parse '{path.name}' as Dataset-JSON: expected top-level " + f"'columns'/'rows' or a 'clinicalData'/'referenceData' section." + ) + + +@dataclass +class SubmissionPackage: + """A data-level model of a study submission package for CDISC conformance validation. + + A `SubmissionPackage` groups the datasets of a study (SDTM domains, SUPP-- qualifiers, + RELREC, and/or ADaM datasets) together with their Define-XML and Controlled Terminology + context, and understands the *relationships* between them. This enables cross-dataset + conformance checks — referential integrity, SUPP-- linkage, RELREC resolution, and + ADaM ⇄ SDTM traceability — that single-dataset validation cannot express. + + This is the data-level analog of [`MetadataPackage`](`pointblank.MetadataPackage`), which + groups *metadata* for many datasets. + + Parameters + ---------- + datasets + A mapping of dataset name (domain code, e.g., `"DM"`, `"AE"`, `"SUPPAE"`, `"ADSL"`) to + the dataset itself (a Pandas or Polars DataFrame). Names are matched case-insensitively + but conventionally uppercase. + define + Optional Define-XML context: a path to a `define.xml` file, or an already-imported + [`MetadataPackage`](`pointblank.MetadataPackage`). Used to supply variable definitions, + codelists, and origins for define-context rules. + ct_version + Optional Controlled Terminology version pin (e.g., `"2024-03-29"`), recorded for + reproducible runs. + standard + The data standard the package follows (`"sdtmig"` or `"adamig"`). Defaults to `"sdtmig"`. + standard_version + The Implementation Guide version (e.g., `"3.4"` for SDTM IG). Defaults to `"3.4"`. + study_id + Optional study identifier, used in report labels. + + Examples + -------- + Construct a package from in-memory DataFrames and validate conformance across it: + + ```python + import pointblank as pb + + study = pb.SubmissionPackage( + datasets={"DM": dm_df, "AE": ae_df, "LB": lb_df}, + standard="sdtmig", + standard_version="3.4", + ) + + report = study.validate_conformance() + report.summary() + ``` + + Or ingest a folder of XPT files (Define-XML auto-detected if present): + + ```python + study = pb.SubmissionPackage.from_folder("study_xyz/sdtm/") + report = study.validate_conformance(agency="FDA") + ``` + """ + + datasets: dict[str, Any] = dataclass_field(default_factory=dict) + define: Any = None + ct_version: str | None = None + standard: str = "sdtmig" + standard_version: str = "3.4" + study_id: str | None = None + + def __post_init__(self) -> None: + # Normalize dataset keys to uppercase for consistent lookup. + self.datasets = {str(k).upper(): v for k, v in self.datasets.items()} + self._metadata: MetadataPackage | None = None + + # ── Construction ───────────────────────────────────────────────────────── + + @classmethod + def from_folder( + cls, + path: str | Path, + define: str | Path | Any | None = None, + standard: str = "sdtmig", + standard_version: str = "3.4", + ct_version: str | None = None, + study_id: str | None = None, + ) -> SubmissionPackage: + """Build a `SubmissionPackage` by ingesting a folder of datasets. + + Reads every SAS Transport (`.xpt`) and CDISC Dataset-JSON (`.json`) file in the folder, + deriving the dataset name from the file stem (uppercased). If a `define.xml` is present + in the folder and `define` is not supplied, it is picked up automatically. + + Parameters + ---------- + path + Path to a folder containing the study datasets. + define + Optional Define-XML path or [`MetadataPackage`](`pointblank.MetadataPackage`). If + `None`, a `define.xml` in the folder is used when present. + standard + The data standard (`"sdtmig"` or `"adamig"`). Defaults to `"sdtmig"`. + standard_version + The Implementation Guide version. Defaults to `"3.4"`. + ct_version + Optional Controlled Terminology version pin. + study_id + Optional study identifier. + + Returns + ------- + SubmissionPackage + A package populated with the folder's datasets. + """ + folder = Path(path) + if not folder.is_dir(): + raise NotADirectoryError(f"Not a directory: {folder}") + + datasets: dict[str, Any] = {} + define_in_folder: Path | None = None + + for f in sorted(folder.iterdir()): + if not f.is_file(): + continue + suffix = f.suffix.lower() + if suffix == ".xpt": + datasets[f.stem.upper()] = _read_xpt_data(f) + elif suffix == ".json": + try: + df, name = _read_dataset_json(f) + except ValueError: + # Not a Dataset-JSON file (could be Frictionless/CSVW); skip it. + continue + datasets[(name or f.stem).upper()] = df + elif suffix == ".xml" and f.name.lower().startswith("define"): + define_in_folder = f + + if define is None and define_in_folder is not None: + define = define_in_folder + + return cls( + datasets=datasets, + define=define, + ct_version=ct_version, + standard=standard, + standard_version=standard_version, + study_id=study_id, + ) + + # ── Dataset graph accessors ────────────────────────────────────────────── + + @property + def domains(self) -> list[str]: + """The names (domain codes) of all datasets in the package, sorted.""" + return sorted(self.datasets.keys()) + + def __contains__(self, name: str) -> bool: + return name.upper() in self.datasets + + def __getitem__(self, name: str) -> Any: + return self.datasets[name.upper()] + + def __len__(self) -> int: + return len(self.datasets) + + def get_dataset(self, name: str) -> Any: + """Get a dataset by name (case-insensitive). + + Parameters + ---------- + name + The dataset name / domain code. + + Returns + ------- + Any + The dataset (DataFrame). + + Raises + ------ + KeyError + If no dataset with that name exists. + """ + key = name.upper() + if key not in self.datasets: + raise KeyError(f"No dataset named '{name}'. Available: {self.domains}") + return self.datasets[key] + + @property + def metadata(self) -> MetadataPackage | None: + """The imported Define-XML metadata, if `define` was supplied. + + Lazily imports the Define-XML document (via + [`import_metadata()`](`pointblank.import_metadata`)) the first time it is accessed. + """ + if self._metadata is not None: + return self._metadata + if self.define is None: + return None + + from pointblank.metadata._types import MetadataPackage + + if isinstance(self.define, MetadataPackage): + self._metadata = self.define + elif isinstance(self.define, (str, Path)): + from pointblank.metadata._import import import_metadata + + imported = import_metadata(self.define, format="cdisc_define") + # import_metadata returns a MetadataPackage for Define-XML + self._metadata = imported if isinstance(imported, MetadataPackage) else None + return self._metadata + + # ── Cross-dataset operators ────────────────────────────────────────────── + + def subject_ids(self, dataset: str = "DM") -> set: + """Get the set of `USUBJID` values in a dataset. + + Parameters + ---------- + dataset + The dataset to read subject IDs from. Defaults to `"DM"` (the reference set of + all enrolled subjects). + + Returns + ------- + set + The set of non-null `USUBJID` values, or an empty set if the dataset or column + is absent. + """ + key = dataset.upper() + if key not in self.datasets: + return set() + return _column_value_set(self.datasets[key], "USUBJID") + + def orphan_ids(self, child: str, parent: str = "DM", column: str = "USUBJID") -> set: + """Find values of `column` in `child` that do not exist in `parent`. + + This is the referential-integrity operator: e.g., subjects appearing in a finding + domain that have no corresponding record in DM. + + Parameters + ---------- + child + The referencing dataset (e.g., `"AE"`). + parent + The referenced dataset (e.g., `"DM"`). Defaults to `"DM"`. + column + The key column to check. Defaults to `"USUBJID"`. + + Returns + ------- + set + The set of orphaned values (present in `child.column` but not `parent.column`). + """ + child_vals = _column_value_set(self.get_dataset(child), column) + parent_vals = _column_value_set(self.get_dataset(parent), column) + return child_vals - parent_vals + + # ── Conformance validation ─────────────────────────────────────────────── + + def validate_conformance( + self, + agency: str | None = None, + cross_dataset: bool = True, + thresholds: Any = None, + interrogate: bool = True, + ) -> ConformanceReport: + """Validate CDISC conformance across the whole submission package. + + For each dataset, this builds a Pointblank [`Validate`](`pointblank.Validate`) plan + combining: + + - the existing single-dataset structural checks (via + [`validate_sdtm()`](`pointblank.validate_sdtm`) / + [`validate_adam()`](`pointblank.validate_adam`)), and + - cross-dataset conformance checks (when `cross_dataset=True`): + - **Referential integrity** — every `USUBJID` in a finding/events/interventions + domain exists in DM. + - **SUPP-- linkage** — `RDOMAIN` references a present domain, `USUBJID` exists in DM, + and `(USUBJID, IDVAR=IDVARVAL)` resolves to a record in the parent domain. + - **RELREC** — each relationship record's `RDOMAIN` is present and `USUBJID` exists + in DM. + - **ADaM ⇄ SDTM traceability** — `ADSL.USUBJID ⊆ DM.USUBJID`, and every other + ADaM dataset's `USUBJID ⊆ ADSL.USUBJID`. + + Parameters + ---------- + agency + Optional agency rule-set selector (`"FDA"`, `"PMDA"`, or `None` for CDISC base + rules). Recorded on the report; agency-specific business rule sets are a later + phase, so this currently affects labeling only. + cross_dataset + Whether to add cross-dataset conformance checks. Defaults to `True`. + thresholds + Optional thresholds passed to each dataset's `Validate` (maps failing test units + onto Pointblank's warning/error/critical severity model). + interrogate + Whether to interrogate (run) the validations before returning. Defaults to `True`. + + Returns + ------- + ConformanceReport + A report aggregating the per-dataset validations, keyed by dataset name. + """ + validations: dict[str, Validate] = {} + + for name in self.domains: + validation = self._build_dataset_validation( + name, cross_dataset=cross_dataset, thresholds=thresholds + ) + if validation is None: + continue + if interrogate: + validation = validation.interrogate() + validations[name] = validation + + return ConformanceReport(validations=validations, package=self, agency=agency) + + def _build_dataset_validation( + self, + name: str, + cross_dataset: bool, + thresholds: Any, + ) -> Validate | None: + """Build the `Validate` plan for one dataset (structural + cross-dataset checks).""" + from pointblank.validate import Validate + + data = self.datasets[name] + + # ── Single-dataset structural checks ── + validation = self._structural_validation(name, data, thresholds) + + # If no structural template applied, start a bare Validate so cross-dataset checks can + # still attach (e.g., SUPP-- or unknown domains that carry USUBJID). + if validation is None: + label = f"CDISC {name} conformance" + if self.study_id: + label = f"CDISC {name} — {self.study_id}" + validation = Validate(data=data, label=label, thresholds=thresholds) + + if cross_dataset: + self._add_cross_dataset_checks(validation, name, data) + + return validation + + def _structural_validation(self, name: str, data: Any, thresholds: Any) -> Validate | None: + """Apply the appropriate single-dataset structural template, if any.""" + # ADaM datasets + if _is_adam(name): + from pointblank.metadata._adam_validate import validate_adam + + # Map a concrete dataset (e.g., ADLB) onto its structural class (BDS) when the exact + # name has no template. ADSL/ADAE/ADTTE have direct templates; others are BDS-class. + dataset_key = _adam_class_for(name) + if dataset_key is None: + return None + try: + return validate_adam( + data=data, + dataset=dataset_key, + study_id=self.study_id, + thresholds=thresholds, + ) + except KeyError: + return None + + # SUPP-- and RELREC have no single-dataset structural template here; handled by + # cross-dataset checks. Everything else is treated as an SDTM domain. + if _is_supp(name) or _is_relrec(name): + return None + + from pointblank.metadata._sdtm_validate import validate_sdtm + + try: + return validate_sdtm( + data=data, + domain=name, + study_id=self.study_id, + thresholds=thresholds, + ) + except KeyError: + # Not a domain we have a template for. + return None + + def _add_cross_dataset_checks(self, validation: Validate, name: str, data: Any) -> None: + """Attach cross-dataset conformance checks to a dataset's `Validate` plan.""" + cols = set(_column_names(data)) + has_dm = "DM" in self.datasets + + # ── SUPP-- linkage ── + if _is_supp(name): + self._add_supp_checks(validation, name, data, cols, has_dm) + return + + # ── RELREC ── + if _is_relrec(name): + self._add_relrec_checks(validation, data, cols, has_dm) + return + + # ── ADaM ⇄ SDTM traceability ── + if _is_adam(name): + self._add_adam_traceability(validation, name, cols) + return + + # ── SDTM referential integrity: USUBJID must exist in DM ── + if name != "DM" and has_dm and "USUBJID" in cols: + valid_ids = self.subject_ids("DM") + validation.specially( + expr=_referential_expr("USUBJID", valid_ids), + brief=f"USUBJID values exist in DM ({name} → DM)", + dimension="consistency", + ) + + def _add_supp_checks( + self, validation: Validate, name: str, data: Any, cols: set, has_dm: bool + ) -> None: + """SUPP-- (Supplemental Qualifiers) linkage checks.""" + present_domains = set(self.datasets.keys()) + + # RDOMAIN must reference a domain present in the package. + if "RDOMAIN" in cols: + validation.specially( + expr=_membership_expr("RDOMAIN", present_domains), + brief=f"{name} RDOMAIN references a present domain", + dimension="consistency", + ) + + # USUBJID must exist in DM. + if has_dm and "USUBJID" in cols: + validation.specially( + expr=_referential_expr("USUBJID", self.subject_ids("DM")), + brief=f"{name} USUBJID values exist in DM", + dimension="consistency", + ) + + # (USUBJID, IDVAR=IDVARVAL) must resolve to a record in the parent domain. + if {"RDOMAIN", "IDVAR", "IDVARVAL", "USUBJID"}.issubset(cols): + validation.specially( + expr=self._supp_idvar_expr(), + brief=f"{name} IDVAR/IDVARVAL resolves in parent domain", + dimension="consistency", + ) + + def _add_relrec_checks( + self, validation: Validate, data: Any, cols: set, has_dm: bool + ) -> None: + """RELREC (Related Records) resolution checks (lightweight).""" + present_domains = set(self.datasets.keys()) + if "RDOMAIN" in cols: + validation.specially( + expr=_membership_expr("RDOMAIN", present_domains), + brief="RELREC RDOMAIN references a present domain", + dimension="consistency", + ) + if has_dm and "USUBJID" in cols: + validation.specially( + expr=_referential_expr("USUBJID", self.subject_ids("DM"), na_pass=True), + brief="RELREC USUBJID values exist in DM", + dimension="consistency", + ) + + def _add_adam_traceability(self, validation: Validate, name: str, cols: set) -> None: + """ADaM ⇄ SDTM (and ADaM ⇄ ADSL) subject-level traceability checks.""" + if "USUBJID" not in cols: + return + + if name == "ADSL": + # ADSL subjects must trace to a DM record. + if "DM" in self.datasets: + validation.specially( + expr=_referential_expr("USUBJID", self.subject_ids("DM")), + brief="ADSL USUBJID values trace to DM", + dimension="consistency", + ) + else: + # Other ADaM datasets must trace to ADSL. + if "ADSL" in self.datasets: + validation.specially( + expr=_referential_expr("USUBJID", self.subject_ids("ADSL")), + brief=f"{name} USUBJID values trace to ADSL", + dimension="consistency", + ) + + def _supp_idvar_expr(self): + """Build a `specially()` callable resolving SUPP IDVAR/IDVARVAL into parent records.""" + datasets = self.datasets + + def check(data: Any) -> list[bool]: + import narwhals as nw + + df = nw.from_native(data, eager_only=True) + rdomain = df["RDOMAIN"].to_list() + idvar = df["IDVAR"].to_list() + idvarval = df["IDVARVAL"].to_list() + usubjid = df["USUBJID"].to_list() + + # Cache of (rdomain, idvar) -> set of (usubjid, str(value)) lookups. + lookups: dict[tuple[str, str], set] = {} + + def _lookup(rdom: str, var: str) -> set | None: + keycache = (rdom, var) + if keycache in lookups: + return lookups[keycache] + parent = datasets.get(rdom) + if parent is None: + lookups[keycache] = None # type: ignore[assignment] + return None + pdf = nw.from_native(parent, eager_only=True) + if var not in pdf.columns or "USUBJID" not in pdf.columns: + lookups[keycache] = None # type: ignore[assignment] + return None + pairs = { + (u, str(v)) + for u, v in zip(pdf["USUBJID"].to_list(), pdf[var].to_list()) + if v is not None + } + lookups[keycache] = pairs + return pairs + + results: list[bool] = [] + for rdom, var, val, usub in zip(rdomain, idvar, idvarval, usubjid): + # Rows with no IDVAR reference a whole-domain qualifier: pass. + if var is None or (isinstance(var, str) and var.strip() == ""): + results.append(True) + continue + pairs = _lookup(str(rdom).upper(), str(var)) + if pairs is None: + # Parent domain / variable absent: cannot resolve, flag as failure. + results.append(False) + continue + results.append((usub, str(val)) in pairs) + return results + + return check + + # ── Reporting helpers ──────────────────────────────────────────────────── + + def summary(self) -> str: + """Return a human-readable summary of the package contents.""" + lines = ["Submission Package"] + if self.study_id: + lines.append(f" Study: {self.study_id}") + lines.append(f" Standard: {self.standard} {self.standard_version}") + if self.ct_version: + lines.append(f" CT version: {self.ct_version}") + if self.define is not None: + lines.append(" Define-XML: present") + lines.append(f" Datasets ({len(self.datasets)}): {', '.join(self.domains)}") + return "\n".join(lines) + + def __str__(self) -> str: + return self.summary() + + def __repr__(self) -> str: + return ( + f"SubmissionPackage(datasets={self.domains}, " + f"standard={self.standard!r}, standard_version={self.standard_version!r})" + ) + + +# ── ADaM template resolution helpers ───────────────────────────────────────── + + +def _adam_template_names() -> set: + """Names of ADaM datasets with a direct structural template.""" + from pointblank.metadata._adam_templates import list_adam_datasets + + return {n.upper() for n in list_adam_datasets()} + + +def _adam_class_for(name: str) -> str | None: + """Map a concrete ADaM dataset name onto its structural class template. + + ADSL, ADAE, ADTTE have direct templates; occurrence/BDS datasets (ADLB, ADVS, ADEG, ...) + validate against the generic BDS structure. + """ + upper = name.upper() + templates = _adam_template_names() + if upper in templates: + return upper + # Occurrence-data ADaM datasets other than ADAE fall back to BDS structure. + if "BDS" in templates: + return "BDS" + return None + + +# ── `specially()` expression factories ─────────────────────────────────────── + + +def _referential_expr(column: str, valid_values: set, na_pass: bool = True): + """Build a `specially()` callable: each row's `column` value is in `valid_values`. + + Null values pass when `na_pass` is `True` (null handling is a separate not-null check). + """ + + def check(data: Any) -> list[bool]: + import narwhals as nw + + df = nw.from_native(data, eager_only=True) + if column not in df.columns: + return [True] + return [ + (True if (v is None and na_pass) else (v in valid_values)) + for v in df[column].to_list() + ] + + return check + + +def _membership_expr(column: str, valid_values: set, na_pass: bool = True): + """Build a `specially()` callable: each row's `column` value is in `valid_values`. + + Distinct from `_referential_expr` only in intent (set membership vs. referential lookup); + the value set is compared case-insensitively for domain codes. + """ + upper_valid = {str(v).upper() for v in valid_values} + + def check(data: Any) -> list[bool]: + import narwhals as nw + + df = nw.from_native(data, eager_only=True) + if column not in df.columns: + return [True] + return [ + (True if (v is None and na_pass) else (str(v).upper() in upper_valid)) + for v in df[column].to_list() + ] + + return check + + +@dataclass +class ConformanceReport: + """The result of [`SubmissionPackage.validate_conformance()`](`pointblank.SubmissionPackage`). + + A `ConformanceReport` comes in one of two forms depending on the validation engine used: + + - **Native** (`engine="native"`, the default) — aggregates the per-dataset + [`Validate`](`pointblank.Validate`) objects produced for the submission package. Each + dataset's validation carries both its single-dataset structural checks and the cross-dataset + conformance checks that reference it. + - **CORE** (`engine="core"`) — wraps the results of the external CDISC CORE engine, holding its + rule-ID-keyed findings, per-rule run statuses, and run provenance. + + The `all_passed()`, `summary()`, `issues()`, and rendering methods work for both forms; use the + `is_core` property to tell them apart. CORE-backed reports additionally expose `findings()` and + `rules()`. + + Parameters + ---------- + validations + A mapping of dataset name to its interrogated `Validate` object (native form). + package + The `SubmissionPackage` the report was produced from. + agency + The agency rule-set selector used for the run (or `None` for CDISC base rules). + core + The parsed CDISC CORE report (CORE form). `None` for native reports. + """ + + validations: dict[str, Validate] = dataclass_field(default_factory=dict) + package: SubmissionPackage | None = None + agency: str | None = None + core: ParsedCoreReport | None = None + + # ── Construction ───────────────────────────────────────────────────────── + + @classmethod + def from_core_report( + cls, + report: dict | ParsedCoreReport, + package: SubmissionPackage | None = None, + agency: str | None = None, + ) -> ConformanceReport: + """Build a CORE-backed `ConformanceReport` from a CDISC CORE JSON report. + + Parameters + ---------- + report + Either a raw CORE JSON report (`dict`, as produced by `core validate -of JSON`) or an + already-parsed [`ParsedCoreReport`](`pointblank.metadata._cdisc_core.ParsedCoreReport`). + package + The `SubmissionPackage` the run was produced from, if any. + agency + The agency rule-set selector used for the run. + + Returns + ------- + ConformanceReport + A report in CORE form (`is_core` is `True`). + """ + from pointblank.metadata._cdisc_core import ParsedCoreReport, parse_core_report + + parsed = report if isinstance(report, ParsedCoreReport) else parse_core_report(report) + return cls(package=package, agency=agency, core=parsed) + + @property + def is_core(self) -> bool: + """Whether this report wraps CDISC CORE engine results (vs. native validations).""" + return self.core is not None + + def all_passed(self) -> bool: + """Whether the run reported no conformance failures. + + For native reports, this is `True` when every check in every dataset passed with no failing + test units. For CORE reports, this is `True` when no rule reported an issue or execution + error. + """ + if self.is_core: + return self.core.all_passed + return all(v.all_passed() for v in self.validations.values()) + + def __getitem__(self, name: str) -> Validate: + return self.validations[name.upper()] + + def get_validation(self, name: str) -> Validate: + """Get the `Validate` object for a single dataset (case-insensitive).""" + key = name.upper() + if key not in self.validations: + raise KeyError(f"No validation for '{name}'. Available: {sorted(self.validations)}") + return self.validations[key] + + def summary(self) -> dict: + """Return a summary of the conformance run. + + Returns + ------- + dict + For a **native** report, a mapping of dataset name to a dict with keys `n_steps`, + `n_steps_failed`, `n_failed` (failing test units), and `all_passed`. + + For a **CORE** report, a single dict with keys `standard`, `version`, + `engine_version`, `n_rules`, `status_counts` (rule counts by run status), `n_issues` + (total reported issues), `n_datasets`, and `all_passed`. + """ + if self.is_core: + core = self.core + return { + "standard": core.standard, + "version": core.version, + "engine_version": core.engine_version, + "n_rules": len(core.rules), + "status_counts": core.status_counts(), + "n_issues": core.n_total_issues, + "n_datasets": len(core.datasets), + "all_passed": core.all_passed, + } + + out: dict[str, dict] = {} + for name, v in self.validations.items(): + steps = v.validation_info + n_steps = len(steps) + n_steps_failed = sum(1 for s in steps if not s.all_passed) + n_failed = sum(int(s.n_failed or 0) for s in steps) + out[name] = { + "n_steps": n_steps, + "n_steps_failed": n_steps_failed, + "n_failed": n_failed, + "all_passed": v.all_passed(), + } + return out + + def issues(self, severity: str | None = None, status: str | None = None) -> list[dict]: + """Return the conformance issues found. + + Parameters + ---------- + severity + (Native reports only.) Optional severity filter: `"warning"`, `"error"`, or + `"critical"`. Requires thresholds to have been set on the run. If `None`, all steps + with failing test units are returned. + status + (CORE reports only.) Optional rule-status filter, e.g. `"ISSUE REPORTED"` or + `"EXECUTION ERROR"`. If `None`, all reported issues are returned. + + Returns + ------- + list[dict] + For a **native** report, one dict per failing step, with keys `dataset`, `step`, + `assertion`, `column`, `n_failed`, `n`, and `severity`. + + For a **CORE** report, one dict per (dataset, rule) with reported issues, with keys + `dataset`, `rule_id`, `message`, `issues` (count), and `status`. + """ + if self.is_core: + # Look up each rule's run status by rule id. + status_by_rule = {r.rule_id: r.status for r in self.core.rules} + out: list[dict] = [] + for item in self.core.issue_summary: + item_status = status_by_rule.get(item.rule_id) + if status is not None and item_status != status: + continue + out.append( + { + "dataset": item.dataset, + "rule_id": item.rule_id, + "message": item.message, + "issues": item.issues, + "status": item_status, + } + ) + return out + + issues: list[dict] = [] + for name, v in self.validations.items(): + for s in v.validation_info: + n_failed = int(s.n_failed or 0) + if n_failed == 0: + continue + sev = None + if s.critical: + sev = "critical" + elif s.error: + sev = "error" + elif s.warning: + sev = "warning" + if severity is not None and sev != severity: + continue + issues.append( + { + "dataset": name, + "step": s.i, + "assertion": s.assertion_type, + "column": s.column, + "n_failed": n_failed, + "n": int(s.n or 0), + "severity": sev, + } + ) + return issues + + def findings(self) -> list[CoreFinding]: + """Return the row-level CORE findings (CORE reports only). + + Returns + ------- + list[CoreFinding] + The row-level findings from CORE's `Issue_Details`, or an empty list for native + reports. + """ + return list(self.core.findings) if self.is_core else [] + + def rules(self, status: str | None = None) -> list[CoreRuleResult]: + """Return the per-rule run results (CORE reports only). + + Parameters + ---------- + status + Optional status filter (e.g. `"SUCCESS"`, `"SKIPPED"`, `"ISSUE REPORTED"`, + `"EXECUTION ERROR"`). If `None`, all rules are returned. + + Returns + ------- + list[CoreRuleResult] + The per-rule results from CORE's `Rules_Report`, or an empty list for native reports. + """ + if not self.is_core: + return [] + if status is None: + return list(self.core.rules) + return [r for r in self.core.rules if r.status == status] + + @property + def n_datasets(self) -> int: + """Number of datasets validated.""" + if self.is_core: + return len(self.core.datasets) + return len(self.validations) + + def _repr_html_(self) -> str: + agency = f" — agency: {self.agency}" if self.agency else "" + + if self.is_core: + core = self.core + parts = [f"

CDISC Conformance Report (CORE){agency}

"] + status = "PASS" if core.all_passed else "FAIL" + parts.append( + f"

{core.standard} {core.version} — CORE " + f"{core.engine_version} — {status}

" + ) + counts = core.status_counts() + parts.append("") + if core.issue_summary: + parts.append("" + "") + for item in core.issue_summary: + parts.append( + f"" + f"" + ) + parts.append("
DatasetRuleIssuesMessage
{item.dataset}{item.rule_id}{item.issues}{item.message}
") + return "\n".join(parts) + + parts = [f"

CDISC Conformance Report{agency}

"] + for name, v in self.validations.items(): + parts.append(f"

{name}

") + try: + report = v.get_tabular_report() + html = getattr(report, "_repr_html_", lambda: str(report))() + parts.append(html) + except Exception: # pragma: no cover - defensive + parts.append("

(report unavailable)

") + return "\n".join(parts) + + def __repr__(self) -> str: + if self.is_core: + core = self.core + lines = ["ConformanceReport (CORE)"] + if self.agency: + lines.append(f" Agency: {self.agency}") + lines.append(f" {core.standard} {core.version} — CORE {core.engine_version}") + status = "PASS" if core.all_passed else "FAIL" + counts = core.status_counts() + counts_str = ", ".join(f"{k}={v}" for k, v in sorted(counts.items())) + lines.append(f" {len(core.rules)} rules ({counts_str})") + lines.append(f" {core.n_total_issues} issues — {status}") + return "\n".join(lines) + + lines = ["ConformanceReport"] + if self.agency: + lines.append(f" Agency: {self.agency}") + summary = self.summary() + for name, s in summary.items(): + status = "PASS" if s["all_passed"] else f"FAIL ({s['n_failed']} test units)" + lines.append(f" [{name}] {s['n_steps']} steps — {status}") + return "\n".join(lines) + + def __str__(self) -> str: + return self.__repr__() From d06cbe5c92abe93d4060bf2b9a10190567d7a190 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sat, 4 Jul 2026 16:59:35 -0400 Subject: [PATCH 03/93] Export submission and CORE metadata APIs --- pointblank/metadata/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pointblank/metadata/__init__.py b/pointblank/metadata/__init__.py index 9d8c63eca..677d33d9f 100644 --- a/pointblank/metadata/__init__.py +++ b/pointblank/metadata/__init__.py @@ -17,7 +17,15 @@ list_sdtm_domains, validate_sdtm_structure, ) +from pointblank.metadata._cdisc_core import ( + CoreFinding, + CoreIssueSummary, + CoreRuleResult, + ParsedCoreReport, + parse_core_report, +) from pointblank.metadata._sdtm_validate import sdtm_to_metadata, validate_sdtm +from pointblank.metadata._submission import ConformanceReport, SubmissionPackage from pointblank.metadata._types import ( Codelist, CodelistEntry, @@ -51,4 +59,11 @@ "validate_adam_structure", "adam_to_metadata", "validate_adam", + "SubmissionPackage", + "ConformanceReport", + "CoreFinding", + "CoreRuleResult", + "CoreIssueSummary", + "ParsedCoreReport", + "parse_core_report", ] From d300f9096a18e3d42a543dc9783e1c1eccb7d03f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sat, 4 Jul 2026 16:59:49 -0400 Subject: [PATCH 04/93] Export submission conformance types --- pointblank/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pointblank/__init__.py b/pointblank/__init__.py index 9fea87d40..6a0bdb699 100644 --- a/pointblank/__init__.py +++ b/pointblank/__init__.py @@ -64,11 +64,13 @@ ADaMVariableSpec, Codelist, CodelistEntry, + ConformanceReport, MetadataImport, MetadataPackage, MissingValueCode, SDTMDomainTemplate, SDTMVariableSpec, + SubmissionPackage, VariableMetadata, adam_to_metadata, export_metadata, @@ -217,4 +219,7 @@ "validate_adam_structure", "adam_to_metadata", "validate_adam", + # CDISC submission-package conformance + "SubmissionPackage", + "ConformanceReport", ] From a14408b05c4284e3a11bbb5d4d740b046f06b0e4 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sat, 4 Jul 2026 17:00:04 -0400 Subject: [PATCH 05/93] Create core_report_full.json --- .../cdisc_core/core_report_full.json | 3630 +++++++++++++++++ 1 file changed, 3630 insertions(+) create mode 100644 tests/metadata_fixtures/cdisc_core/core_report_full.json diff --git a/tests/metadata_fixtures/cdisc_core/core_report_full.json b/tests/metadata_fixtures/cdisc_core/core_report_full.json new file mode 100644 index 000000000..439a5c8f6 --- /dev/null +++ b/tests/metadata_fixtures/cdisc_core/core_report_full.json @@ -0,0 +1,3630 @@ +{ + "Conformance_Details": { + "Report_Generation": "2026-07-03T19:37:12", + "Total_Runtime": "7.8 seconds", + "CORE_Engine_Version": "0.16.0", + "Issue_Limit_Per_Rule": "None", + "Issue_Limit_Per_Dataset": "None", + "Issue_Limit_Per_Sheet": null, + "Standard": "SDTMIG", + "Version": "V3.4", + "CT_Version": "", + "Define_XML_Version": null + }, + "Dataset_Details": [ + { + "filename": "TEST_DATASET", + "label": "Exposure", + "path": "tests/resources/report_test_data", + "modification_date": "2020-08-21T09:14:26", + "size_kb": 823.12, + "length": 1583 + } + ], + "Issue_Summary": [ + { + "dataset": "STUDY", + "core_id": "CORE-000581", + "message": "DM dataset is missing.", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000357", + "message": "Supplemental qualifier dataset associated with a split dataset is greater than 8 characters in length", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000510", + "message": "Split dataset name is not 3 or 4 characters in length", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000539", + "message": "Split dataset is present but the two-Letter parent domain is missing.", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000598", + "message": "Dataset name does not begin with DOMAIN value", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000778", + "message": "Associated Persons non-supplemental qualifier dataset associated with a split dataset does not have a dataset name with a length greater than 4 and less than, or equal to, 6.", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000929", + "message": "rule evaluation error - evaluation dataset failed to build", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-001081", + "message": "rule evaluation error - evaluation dataset failed to build", + "issues": 1 + } + ], + "Issue_Details": [ + { + "core_id": "CORE-000357", + "message": "Supplemental qualifier dataset associated with a split dataset is greater than 8 characters in length", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": 1, + "SEQ": "", + "variables": [ + "dataset_name" + ], + "values": [ + "TEST_DATASET" + ] + }, + { + "core_id": "CORE-000510", + "message": "Split dataset name is not 3 or 4 characters in length", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": 1, + "SEQ": "", + "variables": [ + "dataset_name" + ], + "values": [ + "TEST_DATASET" + ] + }, + { + "core_id": "CORE-000539", + "message": "Split dataset is present but the two-Letter parent domain is missing.", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": 1, + "SEQ": "", + "variables": [ + "dataset_name", + "$list_dataset_names" + ], + "values": [ + "TEST_DATASET", + "['TEST_DATASET']" + ] + }, + { + "core_id": "CORE-000581", + "message": "DM dataset is missing.", + "executability": "fully executable", + "dataset": "STUDY", + "USUBJID": "", + "row": "", + "SEQ": "", + "variables": [], + "values": [ + "null" + ] + }, + { + "core_id": "CORE-000598", + "message": "Dataset name does not begin with DOMAIN value", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": "", + "SEQ": "", + "variables": [ + "dataset_name" + ], + "values": [ + "TEST_DATASET" + ] + }, + { + "core_id": "CORE-000778", + "message": "Associated Persons non-supplemental qualifier dataset associated with a split dataset does not have a dataset name with a length greater than 4 and less than, or equal to, 6.", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": "", + "SEQ": "", + "variables": [ + "dataset_name" + ], + "values": [ + "TEST_DATASET" + ] + }, + { + "core_id": "CORE-000929", + "message": "rule evaluation error - evaluation dataset failed to build - Error occurred during dataset building", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": "", + "SEQ": "", + "variables": "", + "values": "Failed to build dataset for rule validation. Builder: DefineVariablesWithLibraryMetadataDatasetBuilder, Dataset: TEST_DATASET, Error: name=TEST_DATASET, domain=EX is not found in Define XML" + }, + { + "core_id": "CORE-001081", + "message": "rule evaluation error - evaluation dataset failed to build - Error occurred during dataset building", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": "", + "SEQ": "", + "variables": "", + "values": "Failed to build dataset for rule validation. Builder: DefineVariablesWithLibraryMetadataDatasetBuilder, Dataset: TEST_DATASET, Error: name=TEST_DATASET, domain=EX is not found in Define XML" + } + ], + "Rules_Report": [ + { + "core_id": "CORE-000001", + "version": "1", + "cdisc_rule_id": "CG0176, TIG0405", + "fda_rule_id": "", + "message": "IEORRES is not equal to 'N' when IECAT equals 'INCLUSION'.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000002", + "version": "1", + "cdisc_rule_id": "CG0208", + "fda_rule_id": "", + "message": "SESTDTC is required.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000003", + "version": "1", + "cdisc_rule_id": "CG0299", + "fda_rule_id": "", + "message": "TRLOC is present when TRLOC is not included in the TR domain.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000004", + "version": "1", + "cdisc_rule_id": "CG0101, TIG0366", + "fda_rule_id": "", + "message": "ECOCCUR indicates dose was not given, but ECDOSE is not blank or has a value less than or equal to 0", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000005", + "version": "1", + "cdisc_rule_id": "CG0102", + "fda_rule_id": "", + "message": "EXTRT is PLACEBO, but EXDOSE is not equal to 0.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000006", + "version": "1", + "cdisc_rule_id": "CG0131, TIG0381", + "fda_rule_id": "", + "message": "DTHFL is not \"Y\" or null", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000007", + "version": "1", + "cdisc_rule_id": "CG0435, TIG0587", + "fda_rule_id": "FB0606", + "message": "DTHDTC is populated but DTHFL in DM dataset is not \"Y\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000008", + "version": "1", + "cdisc_rule_id": "CG0132", + "fda_rule_id": "FB0601", + "message": "SSTRESC in SS dataset is \"DEAD\", but DTHFL in DM dataset is not \"Y\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000009", + "version": "1", + "cdisc_rule_id": "CG0152, SEND124.1, TIG0061, TIG0393", + "fda_rule_id": "", + "message": "ELEMENT variable has a non-null value when ETCD has a value of 'UNPLAN'", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000010", + "version": "1", + "cdisc_rule_id": "CG0153, TIG0394", + "fda_rule_id": "", + "message": "ARMCD value length is greater than 20", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000011", + "version": "1", + "cdisc_rule_id": "CG0175, TIG0404", + "fda_rule_id": "", + "message": "IEORRES is not equal to 'Y' when IECAT equals 'EXCLUSION'.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000012", + "version": "1", + "cdisc_rule_id": "CG0040, TIG0319", + "fda_rule_id": "", + "message": "AEOCCUR is present in AE dataset.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000013", + "version": "1", + "cdisc_rule_id": "CG0044, TIG0323", + "fda_rule_id": "", + "message": "AESTAT variable is present in AE dataset.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000014", + "version": "1", + "cdisc_rule_id": "CG0087, TIG0352", + "fda_rule_id": "", + "message": "--OCCUR should only be provided when --PRESP is equal to \"Y\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000015", + "version": "1", + "cdisc_rule_id": "CG0088, TIG0353", + "fda_rule_id": "", + "message": "--PRESP is missing in dataset when --OCCUR is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000016", + "version": "1", + "cdisc_rule_id": "CG0089, TIG0354", + "fda_rule_id": "", + "message": "--PRESP should be populated as \"Y\" when --OCCUR is provided.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000017", + "version": "1", + "cdisc_rule_id": "CG0166, TIG0398", + "fda_rule_id": "", + "message": "RDOMAIN has a missing value when IDVARVAL has a non-missing value.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000018", + "version": "1", + "cdisc_rule_id": "CG0086, TIG0351", + "fda_rule_id": "", + "message": "--OCCUR is blank when --PRESP is equal to \"Y\" and --STAT is not provided.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000019", + "version": "1", + "cdisc_rule_id": "CG0311, SEND3, TIG0211, TIG0486", + "fda_rule_id": "", + "message": "Variable label length should be less than or equal to 40 characters", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000020", + "version": "1", + "cdisc_rule_id": "CG0206, TIG0426", + "fda_rule_id": "", + "message": "TAETORD should be null when ETCD = 'UNPLAN'.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000021", + "version": "1", + "cdisc_rule_id": "CG0397, TIG0559", + "fda_rule_id": "", + "message": "--STRESC should not be blank when either --ORRES is provided or --DRVFL = 'Y' .", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000022", + "version": "1", + "cdisc_rule_id": "CG0041, TIG0320", + "fda_rule_id": "", + "message": "At least one of the Seriousness criteria (AESCAN, AESCONG, AESDISAB, AESDTH, AESHOSP, AESLIFE, AESOD or AESMIE) = 'Y', but AESER = 'N' or empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000023", + "version": "1", + "cdisc_rule_id": "CG0084, TIG0349", + "fda_rule_id": "", + "message": "--TOX present in dataset even though --TOXGR is not", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000024", + "version": "1", + "cdisc_rule_id": "CG0082, TIG0347", + "fda_rule_id": "", + "message": "--BODSYS is not empty and --BDSYCD is empty", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000025", + "version": "1", + "cdisc_rule_id": "CG0177, TIG0406", + "fda_rule_id": "", + "message": "IESTRESC is not equal to IEORRES", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000026", + "version": "1", + "cdisc_rule_id": "CG0468, TIG0598", + "fda_rule_id": "", + "message": "The --TPTNUM variable does not exist when --TPT does exist.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000027", + "version": "1", + "cdisc_rule_id": "CG0328, CG0329, SEND214, TIG0141, TIG0496, TIG0497", + "fda_rule_id": "", + "message": "At least one of TEENRL and TEDUR must be populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000028", + "version": "1", + "cdisc_rule_id": "CG0008, TIG0293", + "fda_rule_id": "", + "message": "--TPTREF is empty and --ELTM is not empty", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000029", + "version": "1", + "cdisc_rule_id": "CG0661, TIG0697", + "fda_rule_id": "", + "message": "--TPTNUM exists in a dataset, but --TPT does not exist. When time points are represented in SDTMIG domains, both --TPT and --TPTNUM must be used.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000030", + "version": "1", + "cdisc_rule_id": "CG0053, TIG0329", + "fda_rule_id": "", + "message": "--REASND should not be present in dataset when --PRESP is not present in dataset", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000031", + "version": "1", + "cdisc_rule_id": "CG0659", + "fda_rule_id": "", + "message": "--EVAL is present. --EVAL must not be used to model QRS data.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000032", + "version": "1", + "cdisc_rule_id": "CG0660", + "fda_rule_id": "", + "message": "--EVALID is present. --EVALID must not be used to model QRS data.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000033", + "version": "1", + "cdisc_rule_id": "CG0065, TIG0337", + "fda_rule_id": "", + "message": "DSDECOD is not equal to \"COMPLETED\" when DSTERM equals \"COMPLETED\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000034", + "version": "1", + "cdisc_rule_id": "CG0069, TIG0339", + "fda_rule_id": "", + "message": "DSSTDTC does not equal DM.DTHDTC, when DSDECOD equals \"DEATH\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000035", + "version": "1", + "cdisc_rule_id": "CG0658, TIG0696", + "fda_rule_id": "", + "message": "VISITDY is populated when SVPRESP is null. VISITDY is the Planned Study Day of a visit. It should not be populated for unplanned visits.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000036", + "version": "1", + "cdisc_rule_id": "CG0657, TIG0695", + "fda_rule_id": "", + "message": "Planned visit is not found in TV.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000037", + "version": "1", + "cdisc_rule_id": "CG0653, TIG0691", + "fda_rule_id": "", + "message": "SVPRESP is not null and not equal to \"Y\". Values should be \"Y\" or null.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000038", + "version": "1", + "cdisc_rule_id": "CG0654, TIG0692", + "fda_rule_id": "", + "message": "SVPRESP is not \"Y\" when SVOCCUR is populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000039", + "version": "1", + "cdisc_rule_id": "CG0655, TIG0693", + "fda_rule_id": "", + "message": "VISITNUM for planned visit is not in TV.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000040", + "version": "1", + "cdisc_rule_id": "CG0656, TIG0694", + "fda_rule_id": "", + "message": "VISITNUM for unplanned visit is present in TV.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000041", + "version": "1", + "cdisc_rule_id": "CG0459, CG0649, TIG0689", + "fda_rule_id": "", + "message": "TSVAL is not populated with an ISO 21090 null flavor or null flavor description but TSVALNF is populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000042", + "version": "1", + "cdisc_rule_id": "CG0647, TIG0687", + "fda_rule_id": "", + "message": "TT dataset is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000043", + "version": "1", + "cdisc_rule_id": "CG0648, TIG0688", + "fda_rule_id": "", + "message": "TP dataset is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000044", + "version": "1", + "cdisc_rule_id": "CG0646, TIG0686", + "fda_rule_id": "", + "message": "SJ dataset is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000045", + "version": "1", + "cdisc_rule_id": "CG0517, TIG0612", + "fda_rule_id": "", + "message": "ARMNRS value is missing when ARMCD value is missing", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000046", + "version": "1", + "cdisc_rule_id": "CG0519, TIG0614", + "fda_rule_id": "", + "message": "ARMNRS is missing when ARM value is missing", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000047", + "version": "1", + "cdisc_rule_id": "CG0518, TIG0613", + "fda_rule_id": "", + "message": "ARM value in DM dataset is not among the values of ARM variable in TA dataset. This is allowed only in a multistage study with incomplete ARM assignment. Please confirm if your study is a multistage assignment study", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000048", + "version": "1", + "cdisc_rule_id": "CG0621, TIG0662", + "fda_rule_id": "", + "message": "--METHOD is present in an interventions domain.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000049", + "version": "1", + "cdisc_rule_id": "CG0507, CG0622, TIG0602, TIG0663", + "fda_rule_id": "", + "message": "--USCHFL is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000050", + "version": "1", + "cdisc_rule_id": "CG0623, TIG0664", + "fda_rule_id": "", + "message": "--RSTIND is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000051", + "version": "1", + "cdisc_rule_id": "CG0624, TIG0665", + "fda_rule_id": "", + "message": "--RSTMOD is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000052", + "version": "1", + "cdisc_rule_id": "CG0625, TIG0666", + "fda_rule_id": "", + "message": "--IMPLBL is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000054", + "version": "1", + "cdisc_rule_id": "CG0627, TIG0668", + "fda_rule_id": "", + "message": "--DTHREL is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000055", + "version": "1", + "cdisc_rule_id": "CG0628, TIG0669", + "fda_rule_id": "", + "message": "--EXCLFL is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000056", + "version": "1", + "cdisc_rule_id": "CG0629, TIG0670", + "fda_rule_id": "", + "message": "--REASEX is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000057", + "version": "1", + "cdisc_rule_id": "CG0509, CG0630, TIG0604, TIG0671", + "fda_rule_id": "", + "message": "FETUSID is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000058", + "version": "1", + "cdisc_rule_id": "CG0631, TIG0672", + "fda_rule_id": "", + "message": "RPHASE is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000059", + "version": "1", + "cdisc_rule_id": "CG0632, TIG0673", + "fda_rule_id": "", + "message": "RPPLDY is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000060", + "version": "1", + "cdisc_rule_id": "CG0633, TIG0674", + "fda_rule_id": "", + "message": "RPPLSTDY is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000061", + "version": "1", + "cdisc_rule_id": "CG0634, TIG0675", + "fda_rule_id": "", + "message": "RPPLENDY is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000064", + "version": "1", + "cdisc_rule_id": "CG0637, TIG0678", + "fda_rule_id": "", + "message": "--RPDY is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000065", + "version": "1", + "cdisc_rule_id": "CG0638, TIG0679", + "fda_rule_id": "", + "message": "--RPSTDY is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000066", + "version": "1", + "cdisc_rule_id": "CG0639, TIG0680", + "fda_rule_id": "", + "message": "--RPENDY is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000067", + "version": "1", + "cdisc_rule_id": "CG0640, TIG0681", + "fda_rule_id": "", + "message": "--DETECT is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000068", + "version": "1", + "cdisc_rule_id": "CG0641, TIG0682", + "fda_rule_id": "", + "message": "AGETXT is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000069", + "version": "1", + "cdisc_rule_id": "CG0642, TIG0683", + "fda_rule_id": "", + "message": "SPECIES is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000070", + "version": "1", + "cdisc_rule_id": "CG0643, TIG0684", + "fda_rule_id": "", + "message": "STRAIN is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000071", + "version": "1", + "cdisc_rule_id": "CG0644, TIG0685", + "fda_rule_id": "", + "message": "SBSTRAIN is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000072", + "version": "1", + "cdisc_rule_id": "CG0542", + "fda_rule_id": "", + "message": "--BEATNO is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000073", + "version": "1", + "cdisc_rule_id": "CG0533", + "fda_rule_id": "", + "message": "RPATHCD is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000074", + "version": "1", + "cdisc_rule_id": "CG0508, TIG0603", + "fda_rule_id": "", + "message": "--IMPLBL is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000075", + "version": "1", + "cdisc_rule_id": "CG0304, TIG0481", + "fda_rule_id": "", + "message": "AEREASND is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000076", + "version": "1", + "cdisc_rule_id": "CG0302", + "fda_rule_id": "", + "message": "TRPORTOT is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000077", + "version": "1", + "cdisc_rule_id": "CG0301", + "fda_rule_id": "", + "message": "TRDIR is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000078", + "version": "1", + "cdisc_rule_id": "CG0300", + "fda_rule_id": "", + "message": "TRLAT is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000079", + "version": "1", + "cdisc_rule_id": "CG0095", + "fda_rule_id": "", + "message": "--LAT is present when --LOC is not present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000080", + "version": "1", + "cdisc_rule_id": "CG0093, TIG0358", + "fda_rule_id": "", + "message": "--TPTREF is present when --ELTM, --TPTNUM, and --TPT are not present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000081", + "version": "1", + "cdisc_rule_id": "CG0056, TIG0330", + "fda_rule_id": "", + "message": "--STAT is present when --PRESP is not present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000082", + "version": "1", + "cdisc_rule_id": "CG0561", + "fda_rule_id": "", + "message": "PESTRESC value is not missing when PEORRES value is missing", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000083", + "version": "1", + "cdisc_rule_id": "CG0553, TIG0641", + "fda_rule_id": "", + "message": "--ORRES is missing even though --LOBXFL= \"Y\" and either --DRVFL is not present in dataset or is not equal to \"Y\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000084", + "version": "1", + "cdisc_rule_id": "CG0057, TIG0331", + "fda_rule_id": "", + "message": "--ENTPT exists in a dataset, but --ENRTPT does not exist.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000085", + "version": "1", + "cdisc_rule_id": "CG0059, TIG0333", + "fda_rule_id": "", + "message": "--STTPT is completed and --STRTPT is empty", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000086", + "version": "1", + "cdisc_rule_id": "CG0075, TIG0341", + "fda_rule_id": "", + "message": "DVSTDTC is earlier than RFICDTC in DM.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000087", + "version": "1", + "cdisc_rule_id": "CG0387, TIG0549", + "fda_rule_id": "", + "message": "AESER is completed, but not equal to \"N\" or \"Y\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000088", + "version": "1", + "cdisc_rule_id": "CG0149, SEND25, TIG0169, TIG0390", + "fda_rule_id": "", + "message": "SETCD value length is greater than 8", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000089", + "version": "1", + "cdisc_rule_id": "CG0106", + "fda_rule_id": "", + "message": "Value for --VAMT is populated, when --TRTV is NULL.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000090", + "version": "1", + "cdisc_rule_id": "CG0164, TIG0397", + "fda_rule_id": "", + "message": "RDOMAIN has a missing value but IDVAR has a non-missing value", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000091", + "version": "1", + "cdisc_rule_id": "CG0108", + "fda_rule_id": "", + "message": "Value for --VAMTU is populated, when --TRTV is NULL.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000092", + "version": "1", + "cdisc_rule_id": "CG0110, CG0111, TIG0374, TIG0375", + "fda_rule_id": "", + "message": "Both --DOSE and --DOSTXT values are populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000093", + "version": "1", + "cdisc_rule_id": "CG0114, TIG0377", + "fda_rule_id": "", + "message": "Missing value for --DOSU, when --DOSE, --DOSTXT or --DOSTOT is provided", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000094", + "version": "1", + "cdisc_rule_id": "CG0112, TIG0376", + "fda_rule_id": "", + "message": "--DOSTXT value is numeric.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000095", + "version": "1", + "cdisc_rule_id": "CG0211, TIG0430", + "fda_rule_id": "", + "message": "ETCD is not 'UNPLAN', when SEUPDES is not empty", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000096", + "version": "1", + "cdisc_rule_id": "CG0115, TIG0378", + "fda_rule_id": "", + "message": "--PORTOT variable is present, when --LOC variable does not exist in a dataset.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000097", + "version": "1", + "cdisc_rule_id": "CG0218, TIG0432", + "fda_rule_id": "", + "message": "EPOCH values don't match between Subject Visits (SV) and Subject Elements (SE) datasets.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000098", + "version": "1", + "cdisc_rule_id": "CG0116, TIG0379", + "fda_rule_id": "", + "message": "--DIR variable is present, when --LOC variable does not exist in a dataset.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000099", + "version": "1", + "cdisc_rule_id": "CG0422, TIG0577", + "fda_rule_id": "", + "message": "Value for --STAT is populated, when --ORRES is populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000100", + "version": "1", + "cdisc_rule_id": "CG0423, TIG0578", + "fda_rule_id": "", + "message": "Missing --TRTV value, when --VAMT value is populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000101", + "version": "1", + "cdisc_rule_id": "CG0427, TIG0581", + "fda_rule_id": "", + "message": "Missing --STRESC value, when --RESCAT value is populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000102", + "version": "1", + "cdisc_rule_id": "CG0428, TIG0582", + "fda_rule_id": "", + "message": "--TOXGR should not be null when --TOX is populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000103", + "version": "1", + "cdisc_rule_id": "CG0429, TIG0583", + "fda_rule_id": "", + "message": "--CAT should not be null when --SCAT is populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000104", + "version": "1", + "cdisc_rule_id": "CG0430, TIG0584", + "fda_rule_id": "", + "message": "--SCAT exists in a dataset, but --CAT does not exist.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000105", + "version": "1", + "cdisc_rule_id": "CG0569, TIG0653", + "fda_rule_id": "FB2602", + "message": "--LOBXFL = 'Y', but --STRESC is empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000106", + "version": "1", + "cdisc_rule_id": "CG0045, TIG0324", + "fda_rule_id": "", + "message": "--ENTPT is completed, but --ENRTPT is not completed.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000107", + "version": "1", + "cdisc_rule_id": "CG0554, TIG0642", + "fda_rule_id": "", + "message": "An appropriate identifier is not present; USUBJID, APID, SPDEVID, SPTOBID, POOLID, SPTOBID, STOCONID should be present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000108", + "version": "1", + "cdisc_rule_id": "CG0133, TIG0382", + "fda_rule_id": "FB0602", + "message": "DD record is present, but DTHFL in DM dataset is not \"Y\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000109", + "version": "1", + "cdisc_rule_id": "CG0547", + "fda_rule_id": "", + "message": "SMSTDTC is null.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000110", + "version": "1", + "cdisc_rule_id": "CG0549, TIG0637", + "fda_rule_id": "", + "message": "--STREFC is null when --ORREF is non-empty or --DRVFL is equal to 'Y'", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000111", + "version": "1", + "cdisc_rule_id": "CG0564, TIG0649", + "fda_rule_id": "", + "message": "--AGENT is present in a dataset other than MS.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000112", + "version": "1", + "cdisc_rule_id": "CG0565, TIG0650", + "fda_rule_id": "", + "message": "--CONC is present in a dataset other than MS.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000113", + "version": "1", + "cdisc_rule_id": "CG0566, TIG0651", + "fda_rule_id": "", + "message": "--CONCU is present in a dataset other than MS.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000114", + "version": "1", + "cdisc_rule_id": "CG0567, TIG0652", + "fda_rule_id": "", + "message": "--EVDTYP is present in a dataset other than MH.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000115", + "version": "1", + "cdisc_rule_id": "CG0570", + "fda_rule_id": "", + "message": "ARM cannot be equal to 'Screen Failure', 'Not Assigned', 'Unplanned Treatment' or 'Not Treated'.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000116", + "version": "1", + "cdisc_rule_id": "CG0619, TIG0660", + "fda_rule_id": "", + "message": "--SPCUFL is not null or equal to 'N'", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000117", + "version": "1", + "cdisc_rule_id": "CG0081, TIG0346", + "fda_rule_id": "", + "message": "--STAT should equal 'NOT DONE' when --PRESP = 'Y' and --OCCUR = null", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000118", + "version": "1", + "cdisc_rule_id": "CG0404, TIG0562", + "fda_rule_id": "", + "message": "--STAT is not present in dataset when --PRESP is equal to \"Y\" and --OCCUR is blank", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000119", + "version": "1", + "cdisc_rule_id": "CG0521, TIG0616", + "fda_rule_id": "", + "message": "ARM is populated, when ARMCD is NULL", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000120", + "version": "1", + "cdisc_rule_id": "CG0522, TIG0617", + "fda_rule_id": "", + "message": "ACTARM is populated, when ACTARMCD is NULL", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000121", + "version": "1", + "cdisc_rule_id": "CG0520, TIG0615", + "fda_rule_id": "", + "message": "Value for ARMNRS is populated, when both ARMCD and ACTARMCD values are populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000122", + "version": "1", + "cdisc_rule_id": "CG0434, TIG0586", + "fda_rule_id": "", + "message": "AGEU is completed, but both AGE and AGETXT are not completed.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000123", + "version": "1", + "cdisc_rule_id": "CG0388, TIG0550", + "fda_rule_id": "", + "message": "AESCAN is completed, but not equal to \"N\" or \"Y\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000124", + "version": "1", + "cdisc_rule_id": "CG0389, TIG0551", + "fda_rule_id": "", + "message": "AESCONG is completed, but not equal to \"N\" or \"Y\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000125", + "version": "1", + "cdisc_rule_id": "CG0390, TIG0552", + "fda_rule_id": "", + "message": "AESDISAB is completed, but not equal to \"N\" or \"Y\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000126", + "version": "1", + "cdisc_rule_id": "CG0391, TIG0553", + "fda_rule_id": "", + "message": "AESDTH is completed, but not equal to \"N\" or \"Y\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000127", + "version": "1", + "cdisc_rule_id": "CG0392, TIG0554", + "fda_rule_id": "", + "message": "AESHOSP is completed, but not equal to \"N\" or \"Y\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000128", + "version": "1", + "cdisc_rule_id": "CG0393, TIG0555", + "fda_rule_id": "", + "message": "AESLIFE is completed, but not equal to \"N\" or \"Y\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000129", + "version": "1", + "cdisc_rule_id": "CG0394, TIG0556", + "fda_rule_id": "", + "message": "AESOD is completed, but not equal to \"N\" or \"Y\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000130", + "version": "1", + "cdisc_rule_id": "CG0395, TIG0557", + "fda_rule_id": "", + "message": "AESMIE is completed, but not equal to \"N\" or \"Y\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000131", + "version": "1", + "cdisc_rule_id": "CG0396, TIG0558", + "fda_rule_id": "", + "message": "AECONTRT is completed, but not equal to \"N\" or \"Y\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000132", + "version": "1", + "cdisc_rule_id": "CG0154, SEND213", + "fda_rule_id": "FB0914", + "message": "ETCD and ELEMENT do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000133", + "version": "1", + "cdisc_rule_id": "CG0426, TIG0580", + "fda_rule_id": "", + "message": "Missing value for --STRESC, when --STRESU is provided", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000134", + "version": "1", + "cdisc_rule_id": "CG0203, TIG0423", + "fda_rule_id": "", + "message": "RDOMAIN is not 'DM' but IDVAR is empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000135", + "version": "1", + "cdisc_rule_id": "CG0204, TIG0424", + "fda_rule_id": "", + "message": "IDVAR is not empty but IDVARVAL is empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000136", + "version": "1", + "cdisc_rule_id": "CG0201, TIG0422", + "fda_rule_id": "", + "message": "IDVARVAL and USUBJID are not completed, but IDVAR equals Sequence Number.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000137", + "version": "1", + "cdisc_rule_id": "CG0100, TIG0365", + "fda_rule_id": "", + "message": "ECOCCUR is not 'N', ECSTAT and ECDOSTXT are both empty but ECDOSE is less than or equal to 0.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000138", + "version": "1", + "cdisc_rule_id": "CG0221, TIG0435", + "fda_rule_id": "", + "message": "--STDY is not null when either --STDTC or DM.RFSTDTC do not contain complete values in their date portion", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000139", + "version": "1", + "cdisc_rule_id": "CG0223, TIG0437", + "fda_rule_id": "", + "message": "--ENDY is not null when either --ENDTC or DM.RFSTDTC do not contain complete values in their date portion", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000140", + "version": "1", + "cdisc_rule_id": "CG0225, TIG0438", + "fda_rule_id": "", + "message": "VISITDY is not null when VISITNUM is not in TV.VISITNUM", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000141", + "version": "1", + "cdisc_rule_id": "CG0240, TIG0448", + "fda_rule_id": "FB0922", + "message": "--TPT and --TPTNUM do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000142", + "version": "1", + "cdisc_rule_id": "CG0241, TIG0449", + "fda_rule_id": "", + "message": "--ELTM is not the same value across records with the same values of DOMAIN, VISITNUM, --TPTREF, and --TPTNUM.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000143", + "version": "1", + "cdisc_rule_id": "CG0246, SEND24, TIG0165, TIG0450", + "fda_rule_id": "", + "message": "ETCD value is greater than 8", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000144", + "version": "1", + "cdisc_rule_id": "CG0247, TIG0451", + "fda_rule_id": "", + "message": "TAETORD is not unique within ARM", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000145", + "version": "1", + "cdisc_rule_id": "CG0255", + "fda_rule_id": "", + "message": "IETESTCD is not unique within TIVERS", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000146", + "version": "1", + "cdisc_rule_id": "CG0256", + "fda_rule_id": "", + "message": "IETESTCD is not unique", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000147", + "version": "1", + "cdisc_rule_id": "CG0257, SEND26, TIG0178, TIG0461", + "fda_rule_id": "", + "message": "Length of TSPARMCD is greater than 8", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000148", + "version": "1", + "cdisc_rule_id": "CG0258, TIG0462", + "fda_rule_id": "", + "message": "Length of TSPARM is greater than 40", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000149", + "version": "1", + "cdisc_rule_id": "CG0259", + "fda_rule_id": "", + "message": "TSVALNF is null when TSVAL is null.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000150", + "version": "1", + "cdisc_rule_id": "CG0260", + "fda_rule_id": "", + "message": "TSVALNF is not null when TSVAL is populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000151", + "version": "1", + "cdisc_rule_id": "CG0261, SEND281", + "fda_rule_id": "", + "message": "TSVAL is null when TSVAL1 is populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000152", + "version": "1", + "cdisc_rule_id": "CG0265, TIG0467", + "fda_rule_id": "", + "message": "There is not a one-to-one relationship between TSVAL and TSVALCD", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000153", + "version": "1", + "cdisc_rule_id": "CG0266", + "fda_rule_id": "", + "message": "TSVCDREF is null when TSVCDVER is populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000154", + "version": "1", + "cdisc_rule_id": "CG0268, SEND246, TIG0167, TIG0470", + "fda_rule_id": "", + "message": "TSSEQ is not unique within TSPARMCD", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000155", + "version": "1", + "cdisc_rule_id": "CG0293, TIG0475", + "fda_rule_id": "", + "message": "ARMCD is not in TA.ARMCD", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000156", + "version": "1", + "cdisc_rule_id": "CG0294, TIG0476", + "fda_rule_id": "", + "message": "ARM is not in TA.ARM", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000157", + "version": "1", + "cdisc_rule_id": "CG0297, TIG0479", + "fda_rule_id": "", + "message": "Length of ARMCD is greater than 20", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000158", + "version": "1", + "cdisc_rule_id": "CG0163, TIG0396", + "fda_rule_id": "", + "message": "IDVAR should not be missing when RDOMAIN is not in (empty, DM).", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000159", + "version": "1", + "cdisc_rule_id": "CG0341, TIG0506", + "fda_rule_id": "", + "message": "--TESTCD is equal to 'OTHER'", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000160", + "version": "1", + "cdisc_rule_id": "CG0342, TIG0507", + "fda_rule_id": "", + "message": "--TRT is equal to 'OTHER'", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000161", + "version": "1", + "cdisc_rule_id": "CG0343, TIG0508", + "fda_rule_id": "", + "message": "--TERM is equal to 'OTHER'", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000162", + "version": "1", + "cdisc_rule_id": "CG0344, TIG0509", + "fda_rule_id": "", + "message": "--TESTCD is equal to 'MULTIPLE'", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000163", + "version": "1", + "cdisc_rule_id": "CG0345, TIG0510", + "fda_rule_id": "", + "message": "--TRT is equal to 'MULTIPLE'", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000164", + "version": "1", + "cdisc_rule_id": "CG0346, TIG0511", + "fda_rule_id": "", + "message": "--TERM is equal to 'MULTIPLE'", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000165", + "version": "1", + "cdisc_rule_id": "CG0090, TIG0355", + "fda_rule_id": "FB3701", + "message": "--TPTREF is missing when --RFTDTC is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000166", + "version": "1", + "cdisc_rule_id": "CG0091, TIG0356", + "fda_rule_id": "", + "message": "--TPT is not present when --TPTNUM is present in a dataset", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000167", + "version": "1", + "cdisc_rule_id": "CG0092, TIG0357", + "fda_rule_id": "", + "message": "--TPTREF must be present when --ELTM is present in a dataset", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000168", + "version": "1", + "cdisc_rule_id": "CG0034, TIG0315", + "fda_rule_id": "", + "message": "VISITNUM is not among VISITNUM in SV domain.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000169", + "version": "1", + "cdisc_rule_id": "CG0185, TIG0414", + "fda_rule_id": "", + "message": "Verify that LBTOXGR value is not from a numeric scale", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000170", + "version": "1", + "cdisc_rule_id": "CG0541, TIG0634", + "fda_rule_id": "", + "message": "Value of --LOBXFL must be 'Y' or null", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000171", + "version": "1", + "cdisc_rule_id": "CG0058, TIG0332", + "fda_rule_id": "", + "message": "--ENTPT should be present when --ENRTPT is present in a dataset", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000172", + "version": "1", + "cdisc_rule_id": "CG0409, SEND249.1, TIG0168, TIG0565", + "fda_rule_id": "", + "message": "STUDYID is not equal to DM.STUDYID", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000173", + "version": "1", + "cdisc_rule_id": "CG0414, TIG0569", + "fda_rule_id": "", + "message": "ETCD is not equal to 'UNPLAN' and not equal to TE.ETCD", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000174", + "version": "1", + "cdisc_rule_id": "CG0356", + "fda_rule_id": "", + "message": "SPECIES is present in DM dataset.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000175", + "version": "1", + "cdisc_rule_id": "CG0357", + "fda_rule_id": "", + "message": "STRAIN is present in DM dataset.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000176", + "version": "1", + "cdisc_rule_id": "CG0358", + "fda_rule_id": "", + "message": "SBSTRAIN is present in DM dataset.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000177", + "version": "1", + "cdisc_rule_id": "CG0227, TIG0440", + "fda_rule_id": "", + "message": "DM.RFENDTC is empty, but --ENRF is completed.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000178", + "version": "1", + "cdisc_rule_id": "CG0172", + "fda_rule_id": "", + "message": "SSSTRESC = 'DEAD', but SSDTC < max DS.DSSTDTC.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000179", + "version": "1", + "cdisc_rule_id": "CG0307, TIG0482", + "fda_rule_id": "FB0916", + "message": "TSPARM and TSPARMCD do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000180", + "version": "1", + "cdisc_rule_id": "CG0308, TIG0483", + "fda_rule_id": "", + "message": "Domain value length is not equal 2.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000181", + "version": "1", + "cdisc_rule_id": "CG0309", + "fda_rule_id": "", + "message": "AP-- Domain value length is not equal to 4.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000182", + "version": "1", + "cdisc_rule_id": "CG0310, SEND2, TIG0128, TIG0485", + "fda_rule_id": "", + "message": "Variable name length is greater than 8.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000183", + "version": "1", + "cdisc_rule_id": "CG0318, TIG0489", + "fda_rule_id": "", + "message": "PP dataset is present in study, but PC dataset is not present in study.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000184", + "version": "1", + "cdisc_rule_id": "CG0083, TIG0348", + "fda_rule_id": "FB0910", + "message": "--BODSYS and --BDSYCD do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000185", + "version": "1", + "cdisc_rule_id": "CG0123, TIG0380", + "fda_rule_id": "", + "message": "Length of ACTARMCD is greater than 20 characters", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000186", + "version": "1", + "cdisc_rule_id": "CG0150, TIG0391", + "fda_rule_id": "", + "message": "SUBJID is not unique within study", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000187", + "version": "1", + "cdisc_rule_id": "CG0168, TIG0400", + "fda_rule_id": "", + "message": "CODTC is populated when IDVAR is also populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000188", + "version": "1", + "cdisc_rule_id": "CG0191", + "fda_rule_id": "", + "message": "The MB dataset is not present when the MS dataset is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000189", + "version": "1", + "cdisc_rule_id": "CG0665, TIG0699", + "fda_rule_id": "", + "message": "AGEU is missing when AGE is provided.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000190", + "version": "1", + "cdisc_rule_id": "CG0666, TIG0700", + "fda_rule_id": "", + "message": "AGE is missing when AGEU is provided.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000191", + "version": "1", + "cdisc_rule_id": "CG0529, TIG0622", + "fda_rule_id": "", + "message": "RFENDTC is missing when ARM is provided.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000192", + "version": "1", + "cdisc_rule_id": "CG0530, TIG0623", + "fda_rule_id": "", + "message": "RFENDTC is not blank when ARMNRS is provided.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000193", + "version": "1", + "cdisc_rule_id": "CG0503", + "fda_rule_id": "", + "message": "MIDS variable is missing when MIDSDTC variable is present in a dataset.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000195", + "version": "1", + "cdisc_rule_id": "CG0338, TIG0503", + "fda_rule_id": "", + "message": "--SCAT is equal to --DECOD.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000196", + "version": "1", + "cdisc_rule_id": "CG0337, TIG0502", + "fda_rule_id": "", + "message": "--CAT is equal to --DECOD.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000197", + "version": "1", + "cdisc_rule_id": "CG0339, TIG0504", + "fda_rule_id": "", + "message": "--CAT is equal to --BODSYS.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000198", + "version": "1", + "cdisc_rule_id": "CG0340, TIG0505", + "fda_rule_id": "", + "message": "--SCAT is equal to --BODSYS.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000199", + "version": "1", + "cdisc_rule_id": "CG0406, SEND64, TIG0563", + "fda_rule_id": "", + "message": "Value length of --TEST > 40.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000200", + "version": "1", + "cdisc_rule_id": "CG0348, TIG0513", + "fda_rule_id": "", + "message": "--ORRES cannot be null when --STAT is null or --DRVFL not equal to 'Y'", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000201", + "version": "1", + "cdisc_rule_id": "CG0029, SEND109, TIG0046, TIG0311", + "fda_rule_id": "", + "message": "USUBJID is not found in DM.USUBJID", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000202", + "version": "1", + "cdisc_rule_id": "CG0419, TIG0574", + "fda_rule_id": "", + "message": "RELTYPE is populated when IDVAR is populated with a 'SEQ' value.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000203", + "version": "1", + "cdisc_rule_id": "CG0411, TIG0567", + "fda_rule_id": "", + "message": "The combination of IDVAR, IDVARVAL, and QNAM is not unique per parent subject record", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000204", + "version": "1", + "cdisc_rule_id": "CG0410, TIG0566", + "fda_rule_id": "", + "message": "Scheduled or Contingent visit is not unique within subject", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000206", + "version": "1", + "cdisc_rule_id": "CG0371, SEND121, TIG0058, TIG0535", + "fda_rule_id": "", + "message": "IDVARVAL does not equal a value of the variable referenced by IDVAR in domain = RDOMAIN.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000207", + "version": "1", + "cdisc_rule_id": "CG0467, SEND78, TIG0276, TIG0597", + "fda_rule_id": "", + "message": "--STDTC is present in a Findings general observation class", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000208", + "version": "1", + "cdisc_rule_id": "CG0512, TIG0607", + "fda_rule_id": "", + "message": "ACTARMCD is not in TA.ARMCD", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000209", + "version": "1", + "cdisc_rule_id": "CG0514, TIG0609", + "fda_rule_id": "", + "message": "ACTARM value in DM dataset is not among the values of ARM variable in the TA dataset. This is allowed only in a multistage study with incomplete ARM assignment. Please confirm if your study is a multistage assignment study.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000210", + "version": "1", + "cdisc_rule_id": "CG0516, TIG0611", + "fda_rule_id": "", + "message": "ARMCD is not present in TA.ARMCD", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000211", + "version": "1", + "cdisc_rule_id": "CG0528", + "fda_rule_id": "", + "message": "Population flag is present in SUPPDM", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000212", + "version": "1", + "cdisc_rule_id": "CG0535, TIG0628", + "fda_rule_id": "", + "message": "DSSCAT is not present when subject has more than one record per Epoch with DSCAT = 'DISPOSITION EVENT'.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000213", + "version": "1", + "cdisc_rule_id": "CG0536, TIG0629", + "fda_rule_id": "", + "message": "More than one record per subject per DSSCAT per EPOCH", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000214", + "version": "1", + "cdisc_rule_id": "CG0538, TIG0631", + "fda_rule_id": "", + "message": "More than 1 record exists per subject per EPOCH with DSCAT = 'DISPOSITION EVENT' and DSSCAT = 'STUDY PARTICIPATION'.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000215", + "version": "1", + "cdisc_rule_id": "CG0539", + "fda_rule_id": "", + "message": "More than 1 record exists per subject per EPOCH with DSCAT = 'DISPOSITION EVENT' and DSSCAT = 'STUDY TREATMENT'.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000216", + "version": "1", + "cdisc_rule_id": "CG0545", + "fda_rule_id": "", + "message": "MIDSTYPE is not unique within subject when MIDSTYPE = TM.MIDSTYPE and TM.TMRPT = 'N'", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000217", + "version": "1", + "cdisc_rule_id": "CG0462, TIG0592", + "fda_rule_id": "", + "message": "ECDOSE is empty, ECOCCUR is not equal to 'N' and ECSTAT is empty, but ECDOSTXT is also empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000218", + "version": "1", + "cdisc_rule_id": "CG0465, SEND118, TIG0055, TIG0595", + "fda_rule_id": "", + "message": "IDVAR is empty but IDVARVAL is not empty", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000219", + "version": "1", + "cdisc_rule_id": "CG0350, SEND44, TIG0515", + "fda_rule_id": "", + "message": "--SCAT is equal to domain dataset label.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000220", + "version": "1", + "cdisc_rule_id": "CG0372, TIG0536", + "fda_rule_id": "", + "message": "--TESTCD > 8 chars or contains more than only letters, numbers, underscores, or starts with a number.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000221", + "version": "1", + "cdisc_rule_id": "CG0417, TIG0572", + "fda_rule_id": "", + "message": "QNAM > 8 chars or contains letters that are in not in uppercase, more than only letters, numbers, underscores, or starts with a number.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000222", + "version": "1", + "cdisc_rule_id": "CG0416, TIG0571", + "fda_rule_id": "", + "message": "QLABEL > 40 chars.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000223", + "version": "1", + "cdisc_rule_id": "CG0513, TIG0608", + "fda_rule_id": "", + "message": "ACTARMCD is empty, but ARMNRS is not completed.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000224", + "version": "1", + "cdisc_rule_id": "CG0515, TIG0610", + "fda_rule_id": "", + "message": "ACTARM is empty, but ARMNRS is not completed.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000225", + "version": "1", + "cdisc_rule_id": "CG0094, TIG0359", + "fda_rule_id": "", + "message": "--STAT should be \"NOT DONE\" when --REASND is provided.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000226", + "version": "1", + "cdisc_rule_id": "CG0352", + "fda_rule_id": "", + "message": "--DTHREL must never be used in SDTM-based data for human clinical trials", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000227", + "version": "1", + "cdisc_rule_id": "CG0178, TIG0407", + "fda_rule_id": "", + "message": "IETEST is not in TI.IETEST", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000228", + "version": "1", + "cdisc_rule_id": "CG0179, TIG0408", + "fda_rule_id": "", + "message": "IETESTCD from IE domain does not exist in IETESTCD of TI domain.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000229", + "version": "1", + "cdisc_rule_id": "CG0361", + "fda_rule_id": "", + "message": "POOLID must be null when USUBJID is populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000230", + "version": "1", + "cdisc_rule_id": "CG0362", + "fda_rule_id": "", + "message": "USUBJID must be null when POOLID is populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000231", + "version": "1", + "cdisc_rule_id": "CG0363", + "fda_rule_id": "", + "message": "RSUBJID when populated cannot equal USUBJID", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000232", + "version": "1", + "cdisc_rule_id": "CG0364", + "fda_rule_id": "", + "message": "When RSUBJID is populated, RUSBJID cannot equal POOLID", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000233", + "version": "1", + "cdisc_rule_id": "CG0365", + "fda_rule_id": "", + "message": "RDEVID must be empty when RSUBJID is populated and RSUBJID must be empty when RDEVID is populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000234", + "version": "1", + "cdisc_rule_id": "CG0366", + "fda_rule_id": "", + "message": "RSUBJID must be missing when RDEVID is populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000235", + "version": "1", + "cdisc_rule_id": "CG0367", + "fda_rule_id": "", + "message": "RSUBJID must equal DM.USUBJID when RSUBJID is populated and RSUBJID does not equal POOLID", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000236", + "version": "1", + "cdisc_rule_id": "CG0079, TIG0345", + "fda_rule_id": "", + "message": "MHSTDTC ^= null and MHSTDTC is on or after the DM.RFSTDTC. The medical history dataset should include the subject's prior history at the start of the trial.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000237", + "version": "1", + "cdisc_rule_id": "CG0027, SEND43, TIG0309", + "fda_rule_id": "", + "message": "--SCAT is equal to --CAT", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000238", + "version": "1", + "cdisc_rule_id": "CG0147, TIG0388", + "fda_rule_id": "", + "message": "RFXENDTC does not equal the latest value of EX.EXSTDTC or EX.EXENDTC", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000239", + "version": "1", + "cdisc_rule_id": "CG0148, TIG0389", + "fda_rule_id": "", + "message": "RFXSTDTC does not equal the earliest value of EX.EXSTDTC", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000240", + "version": "1", + "cdisc_rule_id": "CG0420, TIG0575", + "fda_rule_id": "", + "message": "--STRF is populated when --OCCUR = \"N\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000241", + "version": "1", + "cdisc_rule_id": "CG0421, TIG0576", + "fda_rule_id": "", + "message": "--ENRF is populated when --OCCUR = \"N\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000242", + "version": "1", + "cdisc_rule_id": "CG0353, TIG0518", + "fda_rule_id": "", + "message": "--EXCLFL must never be used in SDTM-based human trials", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000243", + "version": "1", + "cdisc_rule_id": "CG0354, TIG0519", + "fda_rule_id": "", + "message": "--REASEX must never be used in SDTM-based human clinical trials", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000244", + "version": "1", + "cdisc_rule_id": "CG0355, TIG0520", + "fda_rule_id": "", + "message": "--DETECT must never be used in SDTM-based human trials", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000245", + "version": "1", + "cdisc_rule_id": "CG0510, CG0635, TIG0605, TIG0676", + "fda_rule_id": "", + "message": "--NOMDY is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000246", + "version": "1", + "cdisc_rule_id": "CG0511, CG0636, TIG0606, TIG0677", + "fda_rule_id": "", + "message": "--NOMLBL is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000247", + "version": "1", + "cdisc_rule_id": "CG0552, CG0626, TIG0640, TIG0667", + "fda_rule_id": "", + "message": "--RESLOC is present.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000248", + "version": "1", + "cdisc_rule_id": "CG0026, SEND63, TIG0265, TIG0308", + "fda_rule_id": "FB3702", + "message": "--TPTREF value is missing when --RFTDTC is populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000249", + "version": "1", + "cdisc_rule_id": "CG0032, TIG0313", + "fda_rule_id": "", + "message": "Visit Day cannot be found in Trial Visit (TV) domain", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000250", + "version": "1", + "cdisc_rule_id": "CG0078, TIG0344", + "fda_rule_id": "", + "message": "MHENDTC ^= null and MHENDTC is on or after the DM.RFSTDTC. The medical history dataset should include the subject's prior history at the start of the trial.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000251", + "version": "1", + "cdisc_rule_id": "CG0067", + "fda_rule_id": "FB0614", + "message": "SSTRESC in SS dataset is \"DEAD\", but a record is missing in DS dataset where DSDECOD = \"DEATH\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000252", + "version": "1", + "cdisc_rule_id": "CG0136, TIG0385", + "fda_rule_id": "FB0605", + "message": "DSDECOD is 'DEATH' in DS dataset, but DTHFL in DM dataset is not \"Y\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000253", + "version": "1", + "cdisc_rule_id": "CG0135, TIG0384", + "fda_rule_id": "FB0604", + "message": "AESDTH is 'Y' in AE dataset, but DTHFL in DM dataset is not \"Y\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000254", + "version": "1", + "cdisc_rule_id": "CG0134, TIG0383", + "fda_rule_id": "FB0603", + "message": "AEOUT is 'FATAL' in AE dataset, but DTHFL in DM dataset is not \"Y\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000256", + "version": "1", + "cdisc_rule_id": "CG0210, SEND125.1, TIG0062, TIG0429", + "fda_rule_id": "", + "message": "Missing value for SEUPDES, when ETCD='UNPLAN'", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000258", + "version": "1", + "cdisc_rule_id": "CG0062, TIG0336", + "fda_rule_id": "", + "message": "--STRTPT is present in dataset, but --STTPT is not present in dataset.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000259", + "version": "1", + "cdisc_rule_id": "CG0171", + "fda_rule_id": "FB0613", + "message": "SSDTC in SS dataset where SSTRESC = 'DEAD' is not equal to or after the DTHDTC in the DM dataset.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000260", + "version": "1", + "cdisc_rule_id": "CG0085, TIG0350", + "fda_rule_id": "", + "message": "--PRESP does not equal 'Y' or is not empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000261", + "version": "1", + "cdisc_rule_id": "CG0060, TIG0334", + "fda_rule_id": "", + "message": "--STTPT is present in dataset, but --STRTPT is not present in dataset.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000262", + "version": "1", + "cdisc_rule_id": "CG0226, TIG0439", + "fda_rule_id": "", + "message": "RFSTDTC in DM dataset is empty but --STRF is completed.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000264", + "version": "1", + "cdisc_rule_id": "CG0039", + "fda_rule_id": "", + "message": "Primary analysis used but --BODSYS and --SOC are not equal", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000266", + "version": "1", + "cdisc_rule_id": "CG0042, TIG0321", + "fda_rule_id": "", + "message": "If AESER = \"N\" then none of the seriousness criteria (AESCAN, AESCONG, AESDISAB, AESDTH, AESHOSP, AESLIFE, AESOD, AESMIE) could be equal to \"Y\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000267", + "version": "1", + "cdisc_rule_id": "CG0050, TIG0328", + "fda_rule_id": "", + "message": "--DECOD should be populated when --PTCD is populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000268", + "version": "1", + "cdisc_rule_id": "CG0049, TIG0327", + "fda_rule_id": "FB0909", + "message": "--DECOD and --PTCD do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000269", + "version": "1", + "cdisc_rule_id": "CG0031, TIG0312", + "fda_rule_id": "", + "message": "For a planned visit (SVPRESP = 'Y'), non-missing VISIT value must be among those present in TV.VISIT.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000270", + "version": "1", + "cdisc_rule_id": "CG0033, TIG0314", + "fda_rule_id": "", + "message": "For a planned visit (SVPRESP = 'Y'), non-missing VISITNUM value must be among those present in TV.VISITNUM. in TV.VISITNUM", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000271", + "version": "1", + "cdisc_rule_id": "CG0009, TIG0294", + "fda_rule_id": "", + "message": "EPOCH is not in TA.EPOCH", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000272", + "version": "1", + "cdisc_rule_id": "CG0336, SEND46, TIG0501", + "fda_rule_id": "", + "message": "--CAT is equal to DOMAIN.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000289", + "version": "1", + "cdisc_rule_id": "CG0181, TIG0410", + "fda_rule_id": "", + "message": "LBORRES is not a continuous measurement but LBORNRHI is not empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000290", + "version": "1", + "cdisc_rule_id": "CG0180, TIG0409", + "fda_rule_id": "", + "message": "LBORRES is not a continuous measurement but LBORNRLO is not empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000291", + "version": "1", + "cdisc_rule_id": "CG0105, TIG0370", + "fda_rule_id": "", + "message": "EXVAMT is present when EC exists.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000292", + "version": "1", + "cdisc_rule_id": "CG0107", + "fda_rule_id": "", + "message": "EXVAMTU is present when EC domain exists.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000293", + "version": "1", + "cdisc_rule_id": "CG0205, TIG0425", + "fda_rule_id": "", + "message": "Length of dataset name of SUPP-- dataset is greater than 8.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000294", + "version": "1", + "cdisc_rule_id": "CG0270, TIG0471", + "fda_rule_id": "", + "message": "TSVAL is not in ISO 8601 format", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000295", + "version": "1", + "cdisc_rule_id": "CG0550, TIG0638", + "fda_rule_id": "", + "message": "--STREFN is null when --STREFC is populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000296", + "version": "1", + "cdisc_rule_id": "CG0540, TIG0633", + "fda_rule_id": "", + "message": "There are no DS records for subject who was assigned treatment", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000297", + "version": "1", + "cdisc_rule_id": "CG0501", + "fda_rule_id": "", + "message": "TM dataset is missing when MIDS variable is present in one of the dataset.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000298", + "version": "1", + "cdisc_rule_id": "CG0182, TIG0411", + "fda_rule_id": "", + "message": "LBORRES is not a continuous measurment but LBSTNRLO is not empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000299", + "version": "1", + "cdisc_rule_id": "CG0183, TIG0412", + "fda_rule_id": "", + "message": "LBORRES is not a continuous measurement but LBSTNRHI is not empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000302", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0920", + "message": "QNAM and QLABEL do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000303", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0901", + "message": "--TESTCD and --TEST do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000305", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB4005", + "message": "Negative value for --DUR.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000308", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB4002", + "message": "Negative value for --DOSE.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000310", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB4001", + "message": "Negative value for AGE.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000318", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0902", + "message": "ARMCD and ARM do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000321", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3201", + "message": "Study Day of Visit/Collection/Exam (--DY) variable is missing when Date/Time of Collection (--DTC) is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000324", + "version": "1", + "cdisc_rule_id": "CG0234, TIG0443", + "fda_rule_id": "", + "message": "--ENRTPT is not in ('BEFORE', 'COINCIDENT', 'ONGOING', 'UNKNOWN')", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000328", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3202", + "message": "The Study Day of Start of Observation (--STDY) is not present in the dataset when Start Date/Time of Observation (--STDTC) is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000334", + "version": "1", + "cdisc_rule_id": "CG0016, SEND13, TIG0065, TIG0301", + "fda_rule_id": "", + "message": "At least one expected variable is missing from dataset", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000337", + "version": "1", + "cdisc_rule_id": "CG0140, TIG0386", + "fda_rule_id": "", + "message": "Multiple races are collected in SUPPDM but RACE in DM does not equal \"MULTIPLE\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000351", + "version": "1", + "cdisc_rule_id": "CG0151, SEND37, TIG0255, TIG0392", + "fda_rule_id": "", + "message": "USUBJID is not unique within study", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000352", + "version": "1", + "cdisc_rule_id": "CG0207, SEND126, TIG0427", + "fda_rule_id": "", + "message": "SEENDTC is not equal to SESTDTC of the next element.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000354", + "version": "1", + "cdisc_rule_id": "CG0007, TIG0292", + "fda_rule_id": "", + "message": "The date portion of --DTC is not complete date or the date portion of DM.RFSTDTC is not complete date, but --DY is not empty", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000355", + "version": "1", + "cdisc_rule_id": "CG0014, SEND12, TIG0057, TIG0299", + "fda_rule_id": "", + "message": "At least one required variable is missing from dataset", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000356", + "version": "1", + "cdisc_rule_id": "CG0014, SEND12, TIG0057, TIG0299", + "fda_rule_id": "", + "message": "At least one Required variable has a null value", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000357", + "version": "1", + "cdisc_rule_id": "CG0018, TIG0303", + "fda_rule_id": "", + "message": "Supplemental qualifier dataset associated with a split dataset is greater than 8 characters in length", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-000358", + "version": "1", + "cdisc_rule_id": "CG0022, TIG0306", + "fda_rule_id": "", + "message": "LNKGRP variable is not found in any of the other domains.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000361", + "version": "1", + "cdisc_rule_id": "CG0035, TIG0316", + "fda_rule_id": "FB0919", + "message": "VISIT and VISITNUM do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000362", + "version": "1", + "cdisc_rule_id": "CG0037", + "fda_rule_id": "", + "message": "Primary analysis was used but --SOCCD and --BDSYCD are not equal", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000363", + "version": "1", + "cdisc_rule_id": "CG0068, TIG0338", + "fda_rule_id": "", + "message": "The earliest disposition date for \"INFORMED CONSENT OBTAINED\" is not equal to DM.RFICDTC", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000365", + "version": "1", + "cdisc_rule_id": "CG0077", + "fda_rule_id": "", + "message": "MHCAT is grouping all records into one generic group. If no smaller categorization can be applied, then it is not necessary to include or populate this variable.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000370", + "version": "1", + "cdisc_rule_id": "CG0143, TIG0387", + "fda_rule_id": "", + "message": "DM.RFICDTC is not equal to the earliest DSSTDTC, when DSTERM indicates Informed consent obtained.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000374", + "version": "1", + "cdisc_rule_id": "CG0537, TIG0630", + "fda_rule_id": "", + "message": "There is more than one record per subject per EPOCH", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000376", + "version": "1", + "cdisc_rule_id": "CG0349, TIG0514", + "fda_rule_id": "", + "message": "First 2 characters of prefixed variable within custom domain do not match the DOMAIN value.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000384", + "version": "1", + "cdisc_rule_id": "CG0374, TIG0538", + "fda_rule_id": "", + "message": "RELREC.RDOMAIN does not represent a dataset present in the study", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000457", + "version": "1", + "cdisc_rule_id": "CG0373, TIG0537", + "fda_rule_id": "", + "message": "SUPP--.RDOMAIN does not represent a dataset present in the study", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000484", + "version": "1", + "cdisc_rule_id": "CG0200, TIG0421", + "fda_rule_id": "", + "message": "Unique RELID is NOT present on multiple RELREC records within a subject.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000502", + "version": "1", + "cdisc_rule_id": "CG0369, TIG0533", + "fda_rule_id": "", + "message": "RDOMAIN does not represent a dataset present in the study.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000505", + "version": "1", + "cdisc_rule_id": "CG0285", + "fda_rule_id": "TRC1734c", + "message": "TSVAL where TSPARMCD = SSTDTC is not in ISO 8601 format.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000510", + "version": "1", + "cdisc_rule_id": "CG0017", + "fda_rule_id": "", + "message": "Split dataset name is not 3 or 4 characters in length", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-000517", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB4003", + "message": "Negative value for --DOSTOT.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000518", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB4004", + "message": "Negative value for --VAMT.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000522", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB1401", + "message": "Missing DSCAT value for Disposition Event.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000527", + "version": "1", + "cdisc_rule_id": "CG0209, TIG0428", + "fda_rule_id": "", + "message": "SEENDTC is null when element is not the last.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000529", + "version": "1", + "cdisc_rule_id": "CG0006, SEND73, SEND74, TIG0274, TIG0275, TIG0291", + "fda_rule_id": "FB1603", + "message": "--DY is not correctly calculated even though the date portion of --DTC is complete, the date portion of RFSTDTC is complete, and --DY is not empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000534", + "version": "1", + "cdisc_rule_id": "CG0248, SEND221, TIG0148, TIG0452", + "fda_rule_id": "", + "message": "TAETORD is not an integer", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000535", + "version": "1", + "cdisc_rule_id": "CG0620, CG0662, SEND130, SEND130.1, TIG0066, TIG0661", + "fda_rule_id": "", + "message": "--SEQ is not chronological (based on --STDTC) within USUBJID", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000538", + "version": "1", + "cdisc_rule_id": "CG0334, TIG0500", + "fda_rule_id": "", + "message": "RDOMAIN does not match characters 5 and 6 of the dataset name", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000539", + "version": "1", + "cdisc_rule_id": "CG0332, TIG0498", + "fda_rule_id": "", + "message": "Split dataset is present but the two-Letter parent domain is missing.", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-000540", + "version": "1", + "cdisc_rule_id": "CG0333, TIG0499", + "fda_rule_id": "", + "message": "Parent domain referenced in Findings About dataset name is not present in the study", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000541", + "version": "1", + "cdisc_rule_id": "CG0372, TIG0536", + "fda_rule_id": "", + "message": "IETESTCD > 8 chars or contains more than only letters, numbers, underscores, or starts with a number.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000542", + "version": "1", + "cdisc_rule_id": "SEND88, TIG0280", + "fda_rule_id": "FB3102", + "message": "--STRESC is numeric but --STRESN is not populated or not equal to --STRESC.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000544", + "version": "1", + "cdisc_rule_id": "CG0028", + "fda_rule_id": "", + "message": "--SEQ is not a unique number per USUBJID per domain, nor a unique number per POOLID per domain, including when the domain is split into multiple files", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000550", + "version": "1", + "cdisc_rule_id": "CG0013, CG0351, TIG0298", + "fda_rule_id": "", + "message": "Variables not listed in the Model List of Allowed Variables for Observation Class should be in SUPPQUAL.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000552", + "version": "1", + "cdisc_rule_id": "CG0220, TIG0434", + "fda_rule_id": "", + "message": "--STDY is not properly calculated per study day algorithm", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000553", + "version": "1", + "cdisc_rule_id": "CG0222, TIG0436", + "fda_rule_id": "", + "message": "--ENDY is not properly calculated per study day algorithm", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000571", + "version": "1", + "cdisc_rule_id": "CG0024, TIG0307", + "fda_rule_id": "", + "message": "LNKID variable is only found in one domain.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000572", + "version": "1", + "cdisc_rule_id": "CG0235, TIG0444", + "fda_rule_id": "", + "message": "--ENRTPT is not in ('BEFORE', 'COINCIDENT', 'ONGOING', 'AFTER', 'UNKNOWN')", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000575", + "version": "1", + "cdisc_rule_id": "CG0219, SEND65, TIG0266, TIG0433", + "fda_rule_id": "", + "message": "No timing variable is provided", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000579", + "version": "1", + "cdisc_rule_id": "CG0408", + "fda_rule_id": "", + "message": "Dataset has no record.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000580", + "version": "1", + "cdisc_rule_id": "CG0325, TIG0495", + "fda_rule_id": "", + "message": "The combination of TESTRL, TEENRL, and TEDUR is not unique for each ETCD.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000581", + "version": "1", + "cdisc_rule_id": "CG0368, TIG0532", + "fda_rule_id": "TRC1736a, TRC1736c", + "message": "DM dataset is missing.", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-000582", + "version": "1", + "cdisc_rule_id": "CG0262", + "fda_rule_id": "", + "message": "TSVALn is null when TSVAL(n+1) is populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000594", + "version": "1", + "cdisc_rule_id": "CG0359, SEND29, TIG0205, TIG0524", + "fda_rule_id": "", + "message": "Variable label is not in title case.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000597", + "version": "1", + "cdisc_rule_id": "CG0043, TIG0322", + "fda_rule_id": "", + "message": "Missing AESMIE=Y where SUPPAE.QNAM=AESOSP", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000598", + "version": "1", + "cdisc_rule_id": "CG0413, SEND1, TIG0037, TIG0568", + "fda_rule_id": "", + "message": "Dataset name does not begin with DOMAIN value", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-000616", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB4403", + "message": "--STINT is populated, but --TPTREF is missing.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000642", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB4404", + "message": "--ENINT is populated, but --TPTREF is missing.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000643", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB2601", + "message": "BLFL is set to \"Y\", but no value for STRESC is provided", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000655", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB2501", + "message": "Values between ARMCD and ACTARMCD are not matching", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000656", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB2502", + "message": "Values between ARM and ACTARM are not matching", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000657", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3409", + "message": "AEENDTC is populated, when AEOUT = NOT RECOVERED/NOT RESOLVED.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000658", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3412", + "message": "RFICDTC falls after RFXSTDTC.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000659", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3410", + "message": "AEENDTC is missing, when AEOUT = RECOVERED/RESOLVED.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000672", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3901", + "message": "--STNRHI is less than or equal to --STNRLO.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000679", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0608", + "message": "DTHFL in DM dataset is \"Y\" but a record is missing in DS dataset where DSDECOD = \"DEATH\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000685", + "version": "1", + "cdisc_rule_id": "CG0571", + "fda_rule_id": "FB0923", + "message": "--TPT and --TPTNUM do not have a one-to-one relationship per unique value of VISITNUM.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000686", + "version": "1", + "cdisc_rule_id": "CG0572", + "fda_rule_id": "FB0924", + "message": "--TPT and --TPTNUM do not have a one-to-one relationship per unique value of --TPTREF.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000689", + "version": "1", + "cdisc_rule_id": "CG0573", + "fda_rule_id": "FB0925", + "message": "--TPT and --TPTNUM do not have a one-to-one relationship per unique combination of VISITNUM and --TPTREF values.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000699", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3001", + "message": "Standards units are inconsistent within the same test (category, sub-category, specimen and method)", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000700", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3204", + "message": "Date/Time of Collection (--DTC) variable is missing when Study Day of Visit/Collection/Exam (--DY) is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000701", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB2201", + "message": "EPOCH is missing for clinical subject-level observation.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000705", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0610", + "message": "DTHFL is \"Y\", but DTHDTC is not populated in the DM dataset.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000706", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0906", + "message": "--LLTCD and --LLT do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000707", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3208", + "message": "--DY is greater than --ENDY.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000708", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3207", + "message": "--STDY is greater than --ENDY.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000709", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0607", + "message": "CETERM or CEDECOD is 'DEATH' in CE dataset, but DTHFL in DM dataset is not \"Y\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000710", + "version": "1", + "cdisc_rule_id": "CG0233, TIG0442", + "fda_rule_id": "", + "message": "--STRTPT is not in ('BEFORE', 'COINCIDENT', 'AFTER', 'UNKNOWN')", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000711", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3404", + "message": "RFSTDTC falls after RFENDTC.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000712", + "version": "1", + "cdisc_rule_id": "CG0370, TIG0534", + "fda_rule_id": "", + "message": "Value for IDVAR in SUPP-- does not represent a variable present in the dataset referenced in RDOMAIN.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000713", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3411", + "message": "RFICDTC falls after RFSTDTC.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000714", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3408", + "message": "RFXSTDTC falls after RFXENDTC.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000716", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0911", + "message": "--SOCCD and --SOC do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000717", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3401", + "message": "AESTDTC falls after the last DSSTDTC.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000718", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3209", + "message": "--STDTC falls after --ENDTC.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000719", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0908", + "message": "--HLGTCD and --HLGT do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000720", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3406", + "message": "--DTC falls after RFPENDTC.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000723", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0907", + "message": "--HLTCD and --HLT do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000726", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB5701", + "message": "Ethnicity has been collected but the values is not mapped to \"HISPANIC OR LATINO\" or \"NOT HISPANIC OR LATINO\"", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000728", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0904", + "message": "TSVALCD and TSVAL do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000729", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0912", + "message": "INVNAM and INVID do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000732", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3103", + "message": "--STRESC is not numeric but --STRESN is not empty", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000736", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB1116", + "message": "AGE and AGETXT are in the list of TSPARAMCDs with TSVAL populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000739", + "version": "1", + "cdisc_rule_id": "CG0407", + "fda_rule_id": "", + "message": "EX is not present in study when study includes protocol-specified study treatment", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000741", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB1111", + "message": "The set ('INTMODEL’, 'INTTYPE','PCLASS') is not in the list of TSPARAMCD with TSVAL populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000742", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB1110", + "message": "TSVALNF is not equal to NA when TSPARAMCD=INDIC", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000743", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0918", + "message": "IETESTCD and IETEST do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000744", + "version": "1", + "cdisc_rule_id": "CG0174, TIG0403", + "fda_rule_id": "", + "message": "Related record is present in the parent domain dataset but FAOBJ is not equal to the \"TERM\", \"TRT\" or \"DECOD\" of the parent domain.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000745", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0915", + "message": "OIPARMCD and OIPARM do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000746", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB5801", + "message": "Ethnicity Term 'Spanish origin' (SUPPDM.CETHNIC) is collected but is not mapped as ETHNIC = HISPANIC OR LATINO.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000747", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB6001", + "message": "Race Term 'Haitian' or 'Negro' is collected (SUPPDM.CRACE) but is not mapped to RACE = BLACK OR AFRICAN AMERICAN in DM dataset.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000757", + "version": "1", + "cdisc_rule_id": "CG0602, TIG0656", + "fda_rule_id": "", + "message": "Interventions parent record exists and --DECOD = null, but FAOBJ is not equal to --TRT.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000758", + "version": "1", + "cdisc_rule_id": "CG0534, TIG0627", + "fda_rule_id": "", + "message": "Milestone associated with RFSTDTC is start of treatment and ARMNRS is not null and different from 'UNPLANNED TREATMENT', but RFSTDTC is not empty.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000760", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3413", + "message": "RFCSTDTC falls after RFCENDTC.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000761", + "version": "1", + "cdisc_rule_id": "CG0289", + "fda_rule_id": "", + "message": "TSVCDVER is not a valid published version (date)", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000763", + "version": "1", + "cdisc_rule_id": "CG0232, TIG0441", + "fda_rule_id": "", + "message": "--STTPT is equal to the date of collection or assessment but --STRTPT is not in ('BEFORE', 'COINCIDENT', 'UNKNOWN').", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000765", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB2401", + "message": "The submitted dataset is larger than 5 GB", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000766", + "version": "1", + "cdisc_rule_id": "CG0601", + "fda_rule_id": "", + "message": "Related record is present in the parent domain dataset and \"Other, specify\" value was coded.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000767", + "version": "1", + "cdisc_rule_id": "CG0603", + "fda_rule_id": "", + "message": "Parent record exists and --DECOD is not null, but FAOBJ is not equal to --DECOD.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000774", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB4202", + "message": "--STAT = NOT DONE, but --REASND is missing.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000776", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3203", + "message": "Study Day of End of Observation (--ENDY) variable is missing when End Date/Time of Observation (--ENDTC) is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000777", + "version": "1", + "cdisc_rule_id": "CG0502", + "fda_rule_id": "", + "message": "TM dataset is missing when RELMIDS variable is present in any of the dataset.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000778", + "version": "1", + "cdisc_rule_id": "CG0650", + "fda_rule_id": "", + "message": "Associated Persons non-supplemental qualifier dataset associated with a split dataset does not have a dataset name with a length greater than 4 and less than, or equal to, 6.", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-000779", + "version": "1", + "cdisc_rule_id": "CG0376", + "fda_rule_id": "", + "message": "TDSTOFF is not equal to zero or a positive value in ISO 8601 Duration format", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000783", + "version": "1", + "cdisc_rule_id": "CG0314, SEND274, SEND274.1", + "fda_rule_id": "", + "message": "SUPP--.QNAM is present in the dataset, but the value of SUPP--.QNAM equals a variable name defined in the corresponding SDTM version.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000784", + "version": "1", + "cdisc_rule_id": "CG0217, TIG0431", + "fda_rule_id": "", + "message": "TAETORD values don't match between Subject Visits (SV) and Subject Elements (SE) datasets.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000785", + "version": "1", + "cdisc_rule_id": "CG0429, TIG0583", + "fda_rule_id": "", + "message": "IECAT should not be null when IESCAT is populated", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000786", + "version": "1", + "cdisc_rule_id": "CG0430, TIG0584", + "fda_rule_id": "", + "message": "IESCAT exists in a dataset, but IECAT does not exist.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000787", + "version": "1", + "cdisc_rule_id": "CG0291", + "fda_rule_id": "", + "message": "TSVAL is populated with an ISO 21090 null flavor or null flavor description.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000791", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0903", + "message": "ACTARMCD and ACTARM do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000792", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0905", + "message": "--CLASCD and --CLAS do not have a one-to-one relationship.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000793", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB1601", + "message": "Collection study day (--DY) is missing when date/time of collection (--DTC) is populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000841", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0612", + "message": "AEENDTC in AE dataset of AE where AEOUT = 'FATAL' is not equal to DTHDTC in the DM dataset.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000844", + "version": "1", + "cdisc_rule_id": "CG0531", + "fda_rule_id": "", + "message": "RACE in DM equals 'MULTIPLE' but multiple races are not collected in SUPPDM.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000845", + "version": "1", + "cdisc_rule_id": "CG0531", + "fda_rule_id": "", + "message": "RACE in DM equals 'MULTIPLE' but multiple races are not collected in SUPPDM.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000846", + "version": "1", + "cdisc_rule_id": "CG0531", + "fda_rule_id": "", + "message": "RACE in DM equals 'MULTIPLE' but multiple races are not collected in SUPPDM.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000852", + "version": "1", + "cdisc_rule_id": "CG0330, CG0664, SEND48, TIG0698", + "fda_rule_id": "", + "message": "Variables are not in the correct order as shown in SDTM for the observation class.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000853", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB1602", + "message": "Collection study day (--DY) is not populated when date/time of collection (--DTC) is populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000862", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3205", + "message": "Start Date/Time of Observation (--STDTC) variable is missing when Study Day of Start of Observation (--STDY) is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000863", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3101", + "message": "--STRESC is populated with a numeric value, but --STRESN is not populated.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000864", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3206", + "message": "End Date/Time of Observation (--ENDTC) variable is missing when Study Day of End of Observation (--ENDY) is present.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000865", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB4405", + "message": "--ELTM is populated, but --TPTREF is missing.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000866", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3210", + "message": "--DTC falls after --ENDTC.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000867", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB1501", + "message": "Text variable contains leading spaces.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000880", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB4006", + "message": "Negative value for --PDUR.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000885", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB2302", + "message": "A subject has a record in DM but not in EX while participating in an interventional study", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000886", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB2306", + "message": "A subject has a no record in EX but is not populated ARMNRS while participating in an interventional study", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000889", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3902", + "message": "--ORNRHI is less than or equal to --ORNRLO.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000890", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB1502", + "message": "Text variable contains '.' as an entire value.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000892", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB4301", + "message": "End timepoint is populated but start timepoint is missing.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000901", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB6901", + "message": "The values of PPCAT and PCTEST do not match at the same reference timepoint", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000913", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0611", + "message": "DSSTDTC for DEATH record in DS dataset is not equal to DTHDTC in the DM dataset.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000914", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB2603", + "message": "There are multiple records per assigned baseline flag (--BLFL).", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000915", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB2604", + "message": "There are multiple records per assigned last observation before exposure flag (--LOBXFL).", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000916", + "version": "1", + "cdisc_rule_id": "CG0370, TIG0534", + "fda_rule_id": "", + "message": "Value for IDVAR in RELREC does not represent a variable present in the dataset referenced in RDOMAIN.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000927", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB2304", + "message": "A subject has a record in EX but is not assigned to a treatment while participating in an interventional study", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000929", + "version": "1", + "cdisc_rule_id": "CG0001, SEND16, TIG0090, TIG0289", + "fda_rule_id": "", + "message": "DOMAIN Code is not a published DOMAIN Code in CDISC Controlled Terminology.", + "status": "EXECUTION ERROR" + }, + { + "core_id": "CORE-000952", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB3407", + "message": "--ENDTC falls after RFPENDTC.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000953", + "version": "1", + "cdisc_rule_id": "CG0370, TIG0534", + "fda_rule_id": "", + "message": "Value for IDVAR in CO does not represent a variable present in the dataset referenced in RDOMAIN.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-001034", + "version": "1", + "cdisc_rule_id": "CG0562, TIG0648", + "fda_rule_id": "", + "message": "--REPNUM is null or is not unique per subject per test per timing variables.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-001043", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0501", + "message": "Age is missing for a non-screen failure subject.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-001078", + "version": "1", + "cdisc_rule_id": "", + "fda_rule_id": "FB0609", + "message": "DTHFL in DM dataset is \"Y\" but a record is missing in AE dataset where AESDTH = \"Y\" and AEOUT = \"FATAL\".", + "status": "SKIPPED" + }, + { + "core_id": "CORE-001080", + "version": "1", + "cdisc_rule_id": "CG0288", + "fda_rule_id": "", + "message": "TSVALCD is not a valid code in the CDISC CT version identified in TSVCDVER.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-001081", + "version": "1", + "cdisc_rule_id": "CG0010", + "fda_rule_id": "", + "message": "Variable role specified in the define-xml does not match the variable role in the IG (for domains) or in the model (for custom domains).", + "status": "EXECUTION ERROR" + }, + { + "core_id": "CORE-001082", + "version": "1", + "cdisc_rule_id": "CG0012", + "fda_rule_id": "", + "message": "Variable data type specified in the dataset does not match the variable data type in the IG or the Model.", + "status": "SUCCESS" + } + ] +} \ No newline at end of file From 1681c9149af46ae5c4e21700471e728d0596f64a Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sat, 4 Jul 2026 17:00:10 -0400 Subject: [PATCH 06/93] Create core_report_trimmed.json --- .../cdisc_core/core_report_trimmed.json | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 tests/metadata_fixtures/cdisc_core/core_report_trimmed.json diff --git a/tests/metadata_fixtures/cdisc_core/core_report_trimmed.json b/tests/metadata_fixtures/cdisc_core/core_report_trimmed.json new file mode 100644 index 000000000..e257098a2 --- /dev/null +++ b/tests/metadata_fixtures/cdisc_core/core_report_trimmed.json @@ -0,0 +1,286 @@ +{ + "Conformance_Details": { + "Report_Generation": "2026-07-03T19:37:12", + "Total_Runtime": "7.8 seconds", + "CORE_Engine_Version": "0.16.0", + "Issue_Limit_Per_Rule": "None", + "Issue_Limit_Per_Dataset": "None", + "Issue_Limit_Per_Sheet": null, + "Standard": "SDTMIG", + "Version": "V3.4", + "CT_Version": "", + "Define_XML_Version": null + }, + "Dataset_Details": [ + { + "filename": "TEST_DATASET", + "label": "Exposure", + "path": "tests/resources/report_test_data", + "modification_date": "2020-08-21T09:14:26", + "size_kb": 823.12, + "length": 1583 + } + ], + "Issue_Summary": [ + { + "dataset": "STUDY", + "core_id": "CORE-000581", + "message": "DM dataset is missing.", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000357", + "message": "Supplemental qualifier dataset associated with a split dataset is greater than 8 characters in length", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000510", + "message": "Split dataset name is not 3 or 4 characters in length", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000539", + "message": "Split dataset is present but the two-Letter parent domain is missing.", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000598", + "message": "Dataset name does not begin with DOMAIN value", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000778", + "message": "Associated Persons non-supplemental qualifier dataset associated with a split dataset does not have a dataset name with a length greater than 4 and less than, or equal to, 6.", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-000929", + "message": "rule evaluation error - evaluation dataset failed to build", + "issues": 1 + }, + { + "dataset": "TEST_DATASET", + "core_id": "CORE-001081", + "message": "rule evaluation error - evaluation dataset failed to build", + "issues": 1 + } + ], + "Issue_Details": [ + { + "core_id": "CORE-000357", + "message": "Supplemental qualifier dataset associated with a split dataset is greater than 8 characters in length", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": 1, + "SEQ": "", + "variables": [ + "dataset_name" + ], + "values": [ + "TEST_DATASET" + ] + }, + { + "core_id": "CORE-000510", + "message": "Split dataset name is not 3 or 4 characters in length", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": 1, + "SEQ": "", + "variables": [ + "dataset_name" + ], + "values": [ + "TEST_DATASET" + ] + }, + { + "core_id": "CORE-000539", + "message": "Split dataset is present but the two-Letter parent domain is missing.", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": 1, + "SEQ": "", + "variables": [ + "dataset_name", + "$list_dataset_names" + ], + "values": [ + "TEST_DATASET", + "['TEST_DATASET']" + ] + }, + { + "core_id": "CORE-000581", + "message": "DM dataset is missing.", + "executability": "fully executable", + "dataset": "STUDY", + "USUBJID": "", + "row": "", + "SEQ": "", + "variables": [], + "values": [ + "null" + ] + }, + { + "core_id": "CORE-000598", + "message": "Dataset name does not begin with DOMAIN value", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": "", + "SEQ": "", + "variables": [ + "dataset_name" + ], + "values": [ + "TEST_DATASET" + ] + }, + { + "core_id": "CORE-000778", + "message": "Associated Persons non-supplemental qualifier dataset associated with a split dataset does not have a dataset name with a length greater than 4 and less than, or equal to, 6.", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": "", + "SEQ": "", + "variables": [ + "dataset_name" + ], + "values": [ + "TEST_DATASET" + ] + }, + { + "core_id": "CORE-000929", + "message": "rule evaluation error - evaluation dataset failed to build - Error occurred during dataset building", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": "", + "SEQ": "", + "variables": "", + "values": "Failed to build dataset for rule validation. Builder: DefineVariablesWithLibraryMetadataDatasetBuilder, Dataset: TEST_DATASET, Error: name=TEST_DATASET, domain=EX is not found in Define XML" + }, + { + "core_id": "CORE-001081", + "message": "rule evaluation error - evaluation dataset failed to build - Error occurred during dataset building", + "executability": "fully executable", + "dataset": "TEST_DATASET", + "USUBJID": "", + "row": "", + "SEQ": "", + "variables": "", + "values": "Failed to build dataset for rule validation. Builder: DefineVariablesWithLibraryMetadataDatasetBuilder, Dataset: TEST_DATASET, Error: name=TEST_DATASET, domain=EX is not found in Define XML" + } + ], + "Rules_Report": [ + { + "core_id": "CORE-000357", + "version": "1", + "cdisc_rule_id": "CG0018, TIG0303", + "fda_rule_id": "", + "message": "Supplemental qualifier dataset associated with a split dataset is greater than 8 characters in length", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-000778", + "version": "1", + "cdisc_rule_id": "CG0650", + "fda_rule_id": "", + "message": "Associated Persons non-supplemental qualifier dataset associated with a split dataset does not have a dataset name with a length greater than 4 and less than, or equal to, 6.", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-000581", + "version": "1", + "cdisc_rule_id": "CG0368, TIG0532", + "fda_rule_id": "TRC1736a, TRC1736c", + "message": "DM dataset is missing.", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-000929", + "version": "1", + "cdisc_rule_id": "CG0001, SEND16, TIG0090, TIG0289", + "fda_rule_id": "", + "message": "DOMAIN Code is not a published DOMAIN Code in CDISC Controlled Terminology.", + "status": "EXECUTION ERROR" + }, + { + "core_id": "CORE-000598", + "version": "1", + "cdisc_rule_id": "CG0413, SEND1, TIG0037, TIG0568", + "fda_rule_id": "", + "message": "Dataset name does not begin with DOMAIN value", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-000539", + "version": "1", + "cdisc_rule_id": "CG0332, TIG0498", + "fda_rule_id": "", + "message": "Split dataset is present but the two-Letter parent domain is missing.", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-000510", + "version": "1", + "cdisc_rule_id": "CG0017", + "fda_rule_id": "", + "message": "Split dataset name is not 3 or 4 characters in length", + "status": "ISSUE REPORTED" + }, + { + "core_id": "CORE-001081", + "version": "1", + "cdisc_rule_id": "CG0010", + "fda_rule_id": "", + "message": "Variable role specified in the define-xml does not match the variable role in the IG (for domains) or in the model (for custom domains).", + "status": "EXECUTION ERROR" + }, + { + "core_id": "CORE-000001", + "version": "1", + "cdisc_rule_id": "CG0176, TIG0405", + "fda_rule_id": "", + "message": "IEORRES is not equal to 'N' when IECAT equals 'INCLUSION'.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000002", + "version": "1", + "cdisc_rule_id": "CG0208", + "fda_rule_id": "", + "message": "SESTDTC is required.", + "status": "SKIPPED" + }, + { + "core_id": "CORE-000005", + "version": "1", + "cdisc_rule_id": "CG0102", + "fda_rule_id": "", + "message": "EXTRT is PLACEBO, but EXDOSE is not equal to 0.", + "status": "SUCCESS" + }, + { + "core_id": "CORE-000019", + "version": "1", + "cdisc_rule_id": "CG0311, SEND3, TIG0211, TIG0486", + "fda_rule_id": "", + "message": "Variable label length should be less than or equal to 40 characters", + "status": "SUCCESS" + } + ] +} \ No newline at end of file From 80a87e303c304413aa024902afee3f44c0b5bf62 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sat, 4 Jul 2026 17:00:47 -0400 Subject: [PATCH 07/93] Document CDISC submission conformance APIs --- great-docs.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/great-docs.yml b/great-docs.yml index 6121aa960..eee444036 100644 --- a/great-docs.yml +++ b/great-docs.yml @@ -409,6 +409,20 @@ reference: - name: ADaMVariableSpec members: true + - title: CDISC Submission Conformance + desc: > + Validate an entire study submission package for CDISC conformance, spanning all datasets. + Use `SubmissionPackage` to model a study as a graph of related datasets (with optional + Define-XML and Controlled Terminology context) and `SubmissionPackage.validate_conformance()` + to run single-dataset structural checks plus cross-dataset checks (USUBJID referential + integrity, SUPP-- linkage, RELREC, and ADaM ⇄ SDTM traceability). Results are returned as a + `ConformanceReport`. + contents: + - name: SubmissionPackage + members: true + - name: ConformanceReport + members: true + - title: Integrations desc: > Classes for integrating Pointblank with external observability and monitoring systems. Use From 4cf8a40fc25f0d9f8e36843dd5acb16900c9afdc Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:42:11 -0400 Subject: [PATCH 08/93] Update _cdisc_core.py --- pointblank/metadata/_cdisc_core.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pointblank/metadata/_cdisc_core.py b/pointblank/metadata/_cdisc_core.py index 5d8f52272..41a265c04 100644 --- a/pointblank/metadata/_cdisc_core.py +++ b/pointblank/metadata/_cdisc_core.py @@ -4,9 +4,16 @@ datasets and Define-XML are handed to CORE, it runs the authoritative conformance rule set, and its JSON report is parsed back into Pointblank's [`ConformanceReport`](`pointblank.ConformanceReport`). -This module implements the *parsing* half — turning CORE's JSON report into typed objects. The -subprocess runner and dataset materialization live elsewhere. The parser is written against the -JSON schema emitted by `core validate -of JSON` (verified against CORE 0.16.0): +This module implements: + +- the **parser** — turning CORE's JSON report into typed objects; +- **dataset materialization** (`_write_xpt` / `_materialize_datasets`) — writing in-memory + DataFrames to a temp dir of SAS Transport (XPT) files that CORE can read; and +- the **subprocess runner** (`_CoreRunner`) — discovering an installed CORE executable / command and + invoking its `validate` subcommand. + +The parser is written against the JSON schema emitted by `core validate -of JSON` (verified against +CORE 0.16.0): - `Conformance_Details` — run provenance (standard, version, CT version, engine version, runtime). - `Dataset_Details` — one entry per validated dataset (filename, label, path, size, row count). From 63b4d80880e69fef4290384380ce455611993315 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:42:24 -0400 Subject: [PATCH 09/93] Add CORE discovery and output constants --- pointblank/metadata/_cdisc_core.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pointblank/metadata/_cdisc_core.py b/pointblank/metadata/_cdisc_core.py index 41a265c04..0389d7ccf 100644 --- a/pointblank/metadata/_cdisc_core.py +++ b/pointblank/metadata/_cdisc_core.py @@ -43,6 +43,15 @@ "STATUS_ERROR", ] +# Environment variable naming the CORE executable / command to invoke. +_CORE_ENV_VAR = "POINTBLANK_CDISC_CORE" + +# Executable names to probe on PATH during auto-discovery. +_CORE_EXECUTABLE_NAMES = ("core", "cdisc-rules-engine") + +# File extension CORE appends to the `-o` output stem, keyed by output format. +_OUTPUT_EXTENSIONS = {"JSON": "json", "XLSX": "xlsx", "CSV": "csv"} + # Rule run-status values emitted by CORE in the `Rules_Report` section. STATUS_SUCCESS = "SUCCESS" # rule ran, data conformed STATUS_SKIPPED = "SKIPPED" # rule not applicable / required data absent From 34644b830d363a330e8de0b223189aae360912bd Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:42:45 -0400 Subject: [PATCH 10/93] Add CORE runner and XPT materialization helpers --- pointblank/metadata/_cdisc_core.py | 312 +++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) diff --git a/pointblank/metadata/_cdisc_core.py b/pointblank/metadata/_cdisc_core.py index 0389d7ccf..dbc22259d 100644 --- a/pointblank/metadata/_cdisc_core.py +++ b/pointblank/metadata/_cdisc_core.py @@ -318,3 +318,315 @@ def parse_core_report(report: dict[str, Any]) -> ParsedCoreReport: findings=findings, rules=rules, ) + + +# ── Dataset materialization (in-memory → XPT) ──────────────────────────────── + + +def _write_xpt(data: Any, path: str | Path, table_name: str, file_label: str = "") -> Path: + """Write a DataFrame to a SAS Transport (XPT) file that CORE can read. + + Accepts any narwhals-supported DataFrame (Pandas, Polars, …); it is converted to Pandas for + `pyreadstat.write_xport`. The XPT member (table) name is uppercased and truncated to 8 + characters — the CDISC / classic-XPT limit — which is sufficient for all SDTM/ADaM domain names. + + Parameters + ---------- + data + The DataFrame to write. + path + Destination `.xpt` file path. + table_name + The dataset/domain name to record as the XPT member name (e.g., `"DM"`). + file_label + Optional dataset label. + + Returns + ------- + Path + The path written. + + Raises + ------ + ImportError + If `pyreadstat` is not installed. + """ + try: + import pyreadstat + except ImportError: + raise ImportError( + "The 'pyreadstat' package is required to write XPT files for the CDISC CORE engine. " + "Install it with: pip install pyreadstat" + ) from None + + import narwhals as nw + + pdf = nw.from_native(data, eager_only=True).to_pandas() + dest = Path(path) + member = str(table_name).upper()[:8] + pyreadstat.write_xport(pdf, str(dest), table_name=member, file_label=file_label or "") + return dest + + +def _materialize_datasets(datasets: dict[str, Any], dest_dir: str | Path) -> dict[str, Path]: + """Write each dataset in a mapping to `.xpt` under `dest_dir`. + + Parameters + ---------- + datasets + A mapping of dataset name (domain code) to DataFrame. + dest_dir + Directory to write the XPT files into (created if needed). + + Returns + ------- + dict[str, Path] + A mapping of dataset name to the written XPT path. + """ + out_dir = Path(dest_dir) + out_dir.mkdir(parents=True, exist_ok=True) + written: dict[str, Path] = {} + for name, data in datasets.items(): + xpt_path = out_dir / f"{name.lower()}.xpt" + _write_xpt(data, xpt_path, table_name=name) + written[name] = xpt_path + return written + + +# ── CORE subprocess runner ─────────────────────────────────────────────────── + + +class CoreNotFoundError(RuntimeError): + """Raised when no CDISC CORE executable / command can be discovered.""" + + +class CoreExecutionError(RuntimeError): + """Raised when the CDISC CORE process exits with a non-zero status.""" + + +def _normalize_version(version: str) -> str: + """Normalize a standard version to CORE's hyphenated form (e.g., ``3.4`` → ``3-4``).""" + return str(version).replace(".", "-") + + +def _resolve_core_command(core: str | Sequence[str] | None) -> list[str]: + """Resolve the base command used to invoke CORE. + + Resolution order: + + 1. An explicit `core` argument — a path/name (`str`) or a full command prefix (sequence, e.g. + `["python", "/path/core.py"]` for a repo checkout, or `["docker", "run", ...]`). + 2. The `POINTBLANK_CDISC_CORE` environment variable (split on whitespace to allow a command). + 3. A `core` / `cdisc-rules-engine` executable on `PATH`. + + Returns + ------- + list[str] + The command prefix (before the `validate` subcommand and its flags). + + Raises + ------ + CoreNotFoundError + If nothing resolves. + """ + if core is not None: + if isinstance(core, str): + return [core] + return list(core) + + env_val = os.environ.get(_CORE_ENV_VAR) + if env_val: + return env_val.split() + + for name in _CORE_EXECUTABLE_NAMES: + found = shutil.which(name) + if found: + return [found] + + raise CoreNotFoundError( + "Could not find the CDISC CORE engine. Install the CORE standalone executable " + "(https://github.com/cdisc-org/cdisc-rules-engine/releases) and either put it on your " + f"PATH as 'core', set the {_CORE_ENV_VAR} environment variable to its path (or a full " + "command such as 'python /path/to/core.py'), or pass core=... explicitly. Note: the pip " + "package 'cdisc-rules-engine' is a library only and ships neither the CLI nor the rules " + "cache." + ) + + +class _CoreRunner: + """Discovers and invokes the CDISC CORE engine as an external subprocess. + + Parameters + ---------- + core + How to invoke CORE. A path/name to the CORE executable (`str`), a full command prefix + (sequence — e.g., `["python", "core.py"]` for a repo checkout), or `None` to auto-discover + via the `POINTBLANK_CDISC_CORE` environment variable and then `PATH`. + cwd + Working directory to run CORE from. CORE resolves its bundled `resources/` (rules cache, + report templates) *relative to the current directory*, so when invoking a repo checkout + (`core.py`) this must be the repo root. Standalone executables bundle their resources and + generally do not need this. If `None`, the current process directory is used. + """ + + def __init__( + self, + core: str | Sequence[str] | None = None, + cwd: str | Path | None = None, + ) -> None: + self._command = _resolve_core_command(core) + self._cwd = str(cwd) if cwd is not None else None + + @property + def command(self) -> list[str]: + """The resolved base command used to invoke CORE.""" + return list(self._command) + + @property + def cwd(self) -> str | None: + """The working directory CORE is run from (or `None` for the current directory).""" + return self._cwd + + def run_validate( + self, + data_dir: str | Path, + standard: str, + version: str, + output_stem: str | Path, + define_xml: str | Path | None = None, + controlled_terminology: str | Sequence[str] | None = None, + output_format: str = "JSON", + raw_report: bool = False, + cache: str | Path | None = None, + extra_args: Sequence[str] | None = None, + timeout: float | None = None, + ) -> Path: + """Run `core validate` and return the path to the report it produced. + + Parameters + ---------- + data_dir + Directory of datasets to validate (XPT / Dataset-JSON). + standard + CDISC standard (e.g., `"sdtmig"`). + version + Standard version; hyphenated automatically (e.g., `"3.4"` → `"3-4"`). + output_stem + Output path *stem*. CORE appends the format extension (e.g., `.json`). + define_xml + Optional path to a `define.xml` (passed via `-dxp`; CORE ignores define files placed in + the data directory). + controlled_terminology + Optional CT package name(s) (passed via one or more `-ct`). + output_format + `"JSON"` (default), `"XLSX"`, or `"CSV"`. + raw_report + If `True` (JSON only), request CORE's raw report via `-rr`. + cache + Optional path to CORE's rules cache directory (passed via `-ca`). + extra_args + Additional raw CLI arguments appended verbatim. + timeout + Optional subprocess timeout in seconds. + + Returns + ------- + Path + The path to the report file CORE produced. + + Raises + ------ + CoreExecutionError + If CORE exits non-zero or the expected output file is not produced. + """ + fmt = output_format.upper() + if fmt not in _OUTPUT_EXTENSIONS: + raise ValueError( + f"Unsupported output_format {output_format!r}. " + f"Choose one of {sorted(_OUTPUT_EXTENSIONS)}." + ) + + stem = Path(output_stem) + cmd = [ + *self._command, + "validate", + "-s", + str(standard), + "-v", + _normalize_version(version), + "-d", + str(data_dir), + "-of", + fmt, + "-o", + str(stem), + ] + if define_xml is not None: + cmd += ["-dxp", str(define_xml)] + if controlled_terminology is not None: + cts = ( + [controlled_terminology] + if isinstance(controlled_terminology, str) + else list(controlled_terminology) + ) + for ct in cts: + cmd += ["-ct", str(ct)] + if cache is not None: + cmd += ["-ca", str(cache)] + if raw_report and fmt == "JSON": + cmd += ["-rr"] + if extra_args: + cmd += list(extra_args) + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + cwd=self._cwd, + ) + except FileNotFoundError as e: + raise CoreExecutionError( + f"Failed to launch CDISC CORE (command: {cmd[0]!r}): {e}" + ) from None + + if proc.returncode != 0: + raise CoreExecutionError( + f"CDISC CORE exited with status {proc.returncode}.\n" + f"Command: {' '.join(cmd)}\n" + f"stderr:\n{proc.stderr}" + ) + + produced = Path(f"{stem}.{_OUTPUT_EXTENSIONS[fmt]}") + if not produced.exists(): + raise CoreExecutionError( + f"CDISC CORE completed but the expected report was not found at {produced}.\n" + f"stdout:\n{proc.stdout}" + ) + return produced + + def validate_to_report( + self, + data_dir: str | Path, + standard: str, + version: str, + output_stem: str | Path, + **kwargs: Any, + ) -> ParsedCoreReport: + """Run `core validate` (JSON) and parse the result into a `ParsedCoreReport`. + + Accepts the same keyword arguments as `run_validate` (except `output_format`, which is + forced to `"JSON"`). + """ + kwargs.pop("output_format", None) + report_path = self.run_validate( + data_dir=data_dir, + standard=standard, + version=version, + output_stem=output_stem, + output_format="JSON", + **kwargs, + ) + with open(report_path) as f: + return parse_core_report(json.load(f)) From b7402f8265b8080ed9130b76023bba06905550b8 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:42:57 -0400 Subject: [PATCH 11/93] Add stdlib imports to CDISC core module --- pointblank/metadata/_cdisc_core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pointblank/metadata/_cdisc_core.py b/pointblank/metadata/_cdisc_core.py index dbc22259d..77c252756 100644 --- a/pointblank/metadata/_cdisc_core.py +++ b/pointblank/metadata/_cdisc_core.py @@ -28,8 +28,13 @@ from __future__ import annotations +import json +import os +import shutil +import subprocess from dataclasses import dataclass, field as dataclass_field -from typing import Any +from pathlib import Path +from typing import Any, Sequence __all__ = [ "CoreFinding", From ecdc7d080173dc3824f1a2b7840f5efcbe8ad5d5 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:43:06 -0400 Subject: [PATCH 12/93] Export core report error classes --- pointblank/metadata/_cdisc_core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pointblank/metadata/_cdisc_core.py b/pointblank/metadata/_cdisc_core.py index 77c252756..d8eaa35b8 100644 --- a/pointblank/metadata/_cdisc_core.py +++ b/pointblank/metadata/_cdisc_core.py @@ -42,6 +42,8 @@ "CoreIssueSummary", "ParsedCoreReport", "parse_core_report", + "CoreNotFoundError", + "CoreExecutionError", "STATUS_SUCCESS", "STATUS_SKIPPED", "STATUS_ISSUE", From 273865cd795539bb76ce0a1b96b931fe1e4ac2f4 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:43:26 -0400 Subject: [PATCH 13/93] Add tests for CDISC CORE report integration --- tests/test_cdisc_core.py | 507 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 tests/test_cdisc_core.py diff --git a/tests/test_cdisc_core.py b/tests/test_cdisc_core.py new file mode 100644 index 000000000..20a58fbe6 --- /dev/null +++ b/tests/test_cdisc_core.py @@ -0,0 +1,507 @@ +"""Tests for the CDISC CORE JSON report parser and CORE-backed ConformanceReport (PLAN_06 Phase 2). + +These tests run entirely against captured CORE report fixtures — no CORE engine dependency. The +fixtures were produced by `core validate -s sdtmig -v 3-4 ... -of JSON` against CORE 0.16.0. +""" + +import json +import sys +from pathlib import Path + +import pytest + +from pointblank.metadata import ( + ConformanceReport, + CoreFinding, + CoreRuleResult, + ParsedCoreReport, + parse_core_report, +) +from pointblank.metadata._cdisc_core import ( + STATUS_ERROR, + STATUS_ISSUE, + STATUS_SKIPPED, + STATUS_SUCCESS, + CoreExecutionError, + CoreNotFoundError, + _CoreRunner, + _materialize_datasets, + _normalize_version, + _resolve_core_command, + _write_xpt, +) + +_FIXTURES = Path(__file__).parent / "metadata_fixtures" / "cdisc_core" + + +def _load(name: str) -> dict: + return json.loads((_FIXTURES / name).read_text()) + + +# A stand-in for the CORE CLI: copies a source JSON to `.` and records its argv to +# `.argv.json`, so tests can drive the runner and assert the built command — no real CORE. +_FAKE_CORE_TEMPLATE = '''\ +import json, shutil, sys +args = sys.argv[1:] +def opt(flag): + return args[args.index(flag) + 1] if flag in args else None +stem = opt("-o") +fmt = opt("-of") or "JSON" +ext = {{"JSON": "json", "XLSX": "xlsx", "CSV": "csv"}}[fmt] +with open(stem + ".argv.json", "w") as f: + json.dump(args, f) +shutil.copy({src!r}, stem + "." + ext) +sys.exit({exit_code}) +''' + + +def _make_fake_core(tmp_path: Path, src_json: Path, exit_code: int = 0) -> Path: + script = tmp_path / "fakecore.py" + script.write_text(_FAKE_CORE_TEMPLATE.format(src=str(src_json), exit_code=exit_code)) + return script + + +def _fake_runner(tmp_path: Path, exit_code: int = 0) -> _CoreRunner: + src = _FIXTURES / "core_report_trimmed.json" + script = _make_fake_core(tmp_path, src, exit_code=exit_code) + return _CoreRunner(core=[sys.executable, str(script)]) + + +@pytest.fixture +def full_report() -> dict: + return _load("core_report_full.json") + + +@pytest.fixture +def trimmed_report() -> dict: + return _load("core_report_trimmed.json") + + +# ── Parser ─────────────────────────────────────────────────────────────────── + + +def test_parse_full_report_provenance(full_report): + parsed = parse_core_report(full_report) + assert isinstance(parsed, ParsedCoreReport) + assert parsed.standard == "SDTMIG" + assert parsed.version == "V3.4" + assert parsed.engine_version == "0.16.0" + assert len(parsed.rules) == 430 + assert len(parsed.datasets) == 1 + + +def test_parse_status_counts(full_report): + parsed = parse_core_report(full_report) + counts = parsed.status_counts() + assert counts == { + STATUS_SKIPPED: 344, + STATUS_SUCCESS: 78, + STATUS_ISSUE: 6, + STATUS_ERROR: 2, + } + + +def test_parse_findings_typed(full_report): + parsed = parse_core_report(full_report) + assert all(isinstance(f, CoreFinding) for f in parsed.findings) + f = parsed.findings[0] + assert f.rule_id == "CORE-000357" + assert f.dataset == "TEST_DATASET" + assert f.row == 1 + assert f.variables == ["dataset_name"] + assert f.values == ["TEST_DATASET"] + # Empty-string sentinels normalized to None + assert f.usubjid is None + assert f.seq is None + + +def test_parse_rules_typed_and_is_failing(full_report): + parsed = parse_core_report(full_report) + assert all(isinstance(r, CoreRuleResult) for r in parsed.rules) + failing = parsed.failing_rules() + # 6 ISSUE REPORTED + 2 EXECUTION ERROR = 8 failing rules + assert len(failing) == 8 + assert all(r.is_failing for r in failing) + success = [r for r in parsed.rules if r.status == STATUS_SUCCESS] + assert success and not success[0].is_failing + + +def test_parse_total_issues_and_all_passed(full_report): + parsed = parse_core_report(full_report) + assert parsed.n_total_issues == sum(s.issues for s in parsed.issue_summary) + assert parsed.all_passed is False + + +def test_parse_accepts_already_parsed_via_report(trimmed_report): + # from_core_report should accept both a dict and a ParsedCoreReport + parsed = parse_core_report(trimmed_report) + rep = ConformanceReport.from_core_report(parsed) + assert rep.is_core + assert rep.core is parsed + + +def test_all_passed_true_when_no_failing_rules(): + report = { + "Conformance_Details": {"Standard": "SDTMIG", "Version": "V3.4"}, + "Dataset_Details": [], + "Issue_Summary": [], + "Issue_Details": [], + "Rules_Report": [ + {"core_id": "CORE-1", "status": "SUCCESS"}, + {"core_id": "CORE-2", "status": "SKIPPED"}, + ], + } + parsed = parse_core_report(report) + assert parsed.all_passed is True + + +def test_all_passed_falls_back_to_issue_summary_when_no_rules(): + report = { + "Conformance_Details": {}, + "Issue_Summary": [{"dataset": "DM", "core_id": "CORE-1", "message": "x", "issues": 3}], + "Issue_Details": [], + "Rules_Report": [], + } + parsed = parse_core_report(report) + assert parsed.all_passed is False + + +def test_parse_raises_on_non_dict(): + with pytest.raises(TypeError): + parse_core_report(["not", "a", "dict"]) + + +def test_parse_raises_on_unrecognized_dict(): + with pytest.raises(ValueError): + parse_core_report({"foo": "bar", "baz": 1}) + + +def test_parse_raw_report_ignores_results_data(full_report): + # The --raw-report variant adds a results_data key; parser should ignore it. + raw = dict(full_report) + raw["results_data"] = [{"anything": 1}] + parsed = parse_core_report(raw) + assert len(parsed.rules) == 430 + + +def test_as_int_coercion_edge_cases(): + report = { + "Conformance_Details": {}, + "Issue_Summary": [{"dataset": "DM", "core_id": "C", "message": "m", "issues": ""}], + "Issue_Details": [{"core_id": "C", "message": "m", "dataset": "DM", "row": "5"}], + "Rules_Report": [], + } + parsed = parse_core_report(report) + assert parsed.issue_summary[0].issues == 0 # "" -> 0 + assert parsed.findings[0].row == 5 # "5" -> 5 + + +# ── ConformanceReport (CORE form) ────────────────────────────────────────────── + + +def test_report_is_core_and_all_passed(full_report): + rep = ConformanceReport.from_core_report(full_report, agency="FDA") + assert rep.is_core is True + assert rep.agency == "FDA" + assert rep.all_passed() is False + assert rep.n_datasets == 1 + + +def test_report_summary_core(full_report): + rep = ConformanceReport.from_core_report(full_report) + s = rep.summary() + assert s["standard"] == "SDTMIG" + assert s["version"] == "V3.4" + assert s["engine_version"] == "0.16.0" + assert s["n_rules"] == 430 + assert s["n_issues"] == 8 + assert s["all_passed"] is False + assert s["status_counts"][STATUS_ISSUE] == 6 + + +def test_report_issues_core(full_report): + rep = ConformanceReport.from_core_report(full_report) + issues = rep.issues() + assert len(issues) == 8 + assert all({"dataset", "rule_id", "message", "issues", "status"} <= set(i) for i in issues) + # every issue-summary rule id resolves to a status in the (consistent) full fixture + assert all(i["status"] is not None for i in issues) + + +def test_report_issues_status_filter(full_report): + rep = ConformanceReport.from_core_report(full_report) + errs = rep.issues(status=STATUS_ERROR) + assert {i["rule_id"] for i in errs} == {"CORE-000929", "CORE-001081"} + assert all(i["status"] == STATUS_ERROR for i in errs) + + +def test_report_findings_and_rules_accessors(full_report): + rep = ConformanceReport.from_core_report(full_report) + findings = rep.findings() + assert findings and isinstance(findings[0], CoreFinding) + all_rules = rep.rules() + assert len(all_rules) == 430 + issue_rules = rep.rules(status=STATUS_ISSUE) + assert len(issue_rules) == 6 + assert all(r.status == STATUS_ISSUE for r in issue_rules) + + +def test_report_repr_and_html_core(full_report): + rep = ConformanceReport.from_core_report(full_report, agency="FDA") + text = repr(rep) + assert "ConformanceReport (CORE)" in text + assert "SDTMIG V3.4" in text + assert "FAIL" in text + html = rep._repr_html_() + assert "CORE" in html + assert "SDTMIG" in html + assert "CORE-000357" in html # an issue rule id appears in the table + + +def test_trimmed_fixture_is_internally_consistent(trimmed_report): + # Every rule referenced by Issue_Summary must resolve to a status (no None) in the trimmed set. + rep = ConformanceReport.from_core_report(trimmed_report) + assert all(i["status"] is not None for i in rep.issues()) + + +# ── Native/CORE separation ───────────────────────────────────────────────────── + + +def test_native_report_findings_rules_empty(): + # A native (non-CORE) report returns empty CORE accessors and is_core False. + import pandas as pd + + import pointblank as pb + + dm = pd.DataFrame( + { + "STUDYID": ["S1"], + "DOMAIN": ["DM"], + "USUBJID": ["S1-001"], + "SUBJID": ["001"], + "ARMCD": ["A"], + "ARM": ["Arm A"], + "COUNTRY": ["USA"], + } + ) + rep = pb.SubmissionPackage(datasets={"DM": dm}).validate_conformance() + assert rep.is_core is False + assert rep.findings() == [] + assert rep.rules() == [] + + +# ── Dataset materialization (_write_xpt / _materialize_datasets) ──────────────── + + +def _dm_df(): + import pandas as pd + + return pd.DataFrame( + { + "STUDYID": ["S1", "S1"], + "DOMAIN": ["DM", "DM"], + "USUBJID": ["S1-001", "S1-002"], + } + ) + + +def test_write_xpt_roundtrip(tmp_path): + pyreadstat = pytest.importorskip("pyreadstat") + df = _dm_df() + out = _write_xpt(df, tmp_path / "dm.xpt", table_name="DM", file_label="Demographics") + assert out.exists() + back, meta = pyreadstat.read_xport(str(out)) + assert list(back.columns) == ["STUDYID", "DOMAIN", "USUBJID"] + assert len(back) == 2 + assert meta.table_name == "DM" + + +def test_write_xpt_truncates_member_name(tmp_path): + pyreadstat = pytest.importorskip("pyreadstat") + df = _dm_df() + out = _write_xpt(df, tmp_path / "x.xpt", table_name="SUPPLONGNAME") + _back, meta = pyreadstat.read_xport(str(out)) + assert len(meta.table_name) <= 8 + assert meta.table_name == "SUPPLONG" + + +def test_write_xpt_accepts_polars(tmp_path): + pytest.importorskip("pyreadstat") + pl = pytest.importorskip("polars") + df = pl.from_pandas(_dm_df()) + out = _write_xpt(df, tmp_path / "dm.xpt", table_name="DM") + assert out.exists() + + +def test_materialize_datasets(tmp_path): + pytest.importorskip("pyreadstat") + written = _materialize_datasets({"DM": _dm_df(), "AE": _dm_df()}, tmp_path / "mat") + assert set(written) == {"DM", "AE"} + assert written["DM"].name == "dm.xpt" + assert written["AE"].name == "ae.xpt" + assert all(p.exists() for p in written.values()) + + +# ── CORE command resolution / discovery ──────────────────────────────────────── + + +def test_normalize_version(): + assert _normalize_version("3.4") == "3-4" + assert _normalize_version("3-4") == "3-4" + assert _normalize_version("1.1") == "1-1" + + +def test_resolve_core_command_explicit_str(): + assert _resolve_core_command("core") == ["core"] + + +def test_resolve_core_command_explicit_sequence(): + assert _resolve_core_command(["python", "core.py"]) == ["python", "core.py"] + + +def test_resolve_core_command_from_env(monkeypatch): + monkeypatch.setenv("POINTBLANK_CDISC_CORE", "python /path/core.py") + assert _resolve_core_command(None) == ["python", "/path/core.py"] + + +def test_resolve_core_command_not_found(monkeypatch): + monkeypatch.delenv("POINTBLANK_CDISC_CORE", raising=False) + # Force PATH discovery to fail + monkeypatch.setattr("pointblank.metadata._cdisc_core.shutil.which", lambda name: None) + with pytest.raises(CoreNotFoundError): + _resolve_core_command(None) + + +# ── CORE runner (driven by a fake CORE script) ───────────────────────────────── + + +def test_runner_command_property(tmp_path): + runner = _fake_runner(tmp_path) + assert runner.command[0] == sys.executable + assert runner.command[1].endswith("fakecore.py") + + +def test_run_validate_produces_and_parses(tmp_path): + runner = _fake_runner(tmp_path) + parsed = runner.validate_to_report( + data_dir=tmp_path, + standard="sdtmig", + version="3.4", + output_stem=tmp_path / "out", + ) + assert isinstance(parsed, ParsedCoreReport) + assert parsed.standard == "SDTMIG" + assert len(parsed.rules) == 12 + + +def test_run_validate_builds_expected_command(tmp_path): + runner = _fake_runner(tmp_path) + define = tmp_path / "define.xml" + define.write_text("") + runner.run_validate( + data_dir=tmp_path / "data", + standard="sdtmig", + version="3.4", + output_stem=tmp_path / "out", + define_xml=define, + controlled_terminology=["sdtmct-2024-03-29", "sdtmct-2023-12-15"], + cache=tmp_path / "cache", + raw_report=True, + ) + argv = json.loads((tmp_path / "out.argv.json").read_text()) + # Positional/flag checks + assert argv[0] == "validate" + assert "-s" in argv and argv[argv.index("-s") + 1] == "sdtmig" + assert argv[argv.index("-v") + 1] == "3-4" # hyphenated + assert argv[argv.index("-of") + 1] == "JSON" + assert argv[argv.index("-dxp") + 1] == str(define) + assert argv[argv.index("-ca") + 1] == str(tmp_path / "cache") + assert argv.count("-ct") == 2 + assert "-rr" in argv + + +def test_run_validate_single_ct_string(tmp_path): + runner = _fake_runner(tmp_path) + runner.run_validate( + data_dir=tmp_path, + standard="sdtmig", + version="3.4", + output_stem=tmp_path / "out", + controlled_terminology="sdtmct-2024-03-29", + ) + argv = json.loads((tmp_path / "out.argv.json").read_text()) + assert argv.count("-ct") == 1 + assert argv[argv.index("-ct") + 1] == "sdtmct-2024-03-29" + + +def test_run_validate_unsupported_format(tmp_path): + runner = _fake_runner(tmp_path) + with pytest.raises(ValueError): + runner.run_validate( + data_dir=tmp_path, + standard="sdtmig", + version="3.4", + output_stem=tmp_path / "out", + output_format="PDF", + ) + + +def test_run_validate_nonzero_exit_raises(tmp_path): + runner = _fake_runner(tmp_path, exit_code=2) + with pytest.raises(CoreExecutionError): + runner.run_validate( + data_dir=tmp_path, + standard="sdtmig", + version="3.4", + output_stem=tmp_path / "out", + ) + + +def test_run_validate_missing_output_raises(tmp_path): + # A fake core that exits 0 but writes nothing. + script = tmp_path / "noop.py" + script.write_text("import sys; sys.exit(0)") + runner = _CoreRunner(core=[sys.executable, str(script)]) + with pytest.raises(CoreExecutionError): + runner.run_validate( + data_dir=tmp_path, + standard="sdtmig", + version="3.4", + output_stem=tmp_path / "out", + ) + + +def test_runner_passes_cwd(tmp_path): + # CORE resolves its bundled resources relative to cwd; verify the runner honors cwd=. + src = _FIXTURES / "core_report_trimmed.json" + run_dir = tmp_path / "coreroot" + run_dir.mkdir() + script = tmp_path / "cwdcore.py" + script.write_text( + "import json, os, shutil, sys\n" + "args = sys.argv[1:]\n" + "stem = args[args.index('-o') + 1]\n" + "open(stem + '.cwd.txt', 'w').write(os.getcwd())\n" + f"shutil.copy({str(src)!r}, stem + '.json')\n" + ) + runner = _CoreRunner(core=[sys.executable, str(script)], cwd=run_dir) + assert runner.cwd == str(run_dir) + runner.run_validate( + data_dir=tmp_path, + standard="sdtmig", + version="3.4", + output_stem=tmp_path / "out", + ) + recorded = (tmp_path / "out.cwd.txt").read_text() + assert Path(recorded).resolve() == run_dir.resolve() + + +def test_runner_launch_failure_raises(tmp_path): + runner = _CoreRunner(core=[str(tmp_path / "does-not-exist-binary")]) + with pytest.raises(CoreExecutionError): + runner.run_validate( + data_dir=tmp_path, + standard="sdtmig", + version="3.4", + output_stem=tmp_path / "out", + ) From 59e6decaccbb0467b8cc82f3a56e211b2ab49f9b Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:43:55 -0400 Subject: [PATCH 14/93] Add submission package conformance tests --- tests/test_submission.py | 324 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 tests/test_submission.py diff --git a/tests/test_submission.py b/tests/test_submission.py new file mode 100644 index 000000000..a86b6fe42 --- /dev/null +++ b/tests/test_submission.py @@ -0,0 +1,324 @@ +"""Tests for the CDISC submission-package conformance model.""" + +import json +import shutil +import tempfile +from pathlib import Path + +import pandas as pd +import polars as pl +import pytest + +import pointblank as pb +from pointblank.metadata._submission import ConformanceReport, SubmissionPackage + + +# ── Fixtures ───────────────────────────────────────────────────────────────── + + +def _dm(usubjids=("S1-001", "S1-002", "S1-003")): + n = len(usubjids) + return pd.DataFrame( + { + "STUDYID": ["S1"] * n, + "DOMAIN": ["DM"] * n, + "USUBJID": list(usubjids), + "SUBJID": [u.split("-")[-1] for u in usubjids], + "ARMCD": ["A", "B", "A"][:n], + "ARM": ["Arm A", "Arm B", "Arm A"][:n], + "COUNTRY": ["USA"] * n, + } + ) + + +def _ae(usubjids): + n = len(usubjids) + return pd.DataFrame( + { + "STUDYID": ["S1"] * n, + "DOMAIN": ["AE"] * n, + "USUBJID": list(usubjids), + "AESEQ": list(range(1, n + 1)), + "AETERM": ["Headache", "Nausea", "Fever"][:n], + } + ) + + +# ── Construction & accessors ───────────────────────────────────────────────── + + +def test_construction_and_accessors(): + study = SubmissionPackage(datasets={"dm": _dm(), "AE": _ae(["S1-001", "S1-002"])}) + # Keys normalized to uppercase + assert study.domains == ["AE", "DM"] + assert "DM" in study + assert "dm" in study + assert len(study) == 2 + assert study.get_dataset("dm").shape[0] == 3 + assert study["AE"].shape[0] == 2 + + with pytest.raises(KeyError): + study.get_dataset("LB") + + +def test_top_level_exports(): + assert pb.SubmissionPackage is SubmissionPackage + assert pb.ConformanceReport is ConformanceReport + + +def test_subject_ids_and_orphans(): + study = SubmissionPackage(datasets={"DM": _dm(), "AE": _ae(["S1-001", "S1-999"])}) + assert study.subject_ids("DM") == {"S1-001", "S1-002", "S1-003"} + assert study.subject_ids("MISSING") == set() + assert study.orphan_ids("AE", "DM") == {"S1-999"} + + +def test_summary_and_repr(): + study = SubmissionPackage(datasets={"DM": _dm()}, ct_version="2024-03-29", study_id="XYZ") + s = study.summary() + assert "XYZ" in s + assert "2024-03-29" in s + assert "DM" in s + assert "SubmissionPackage" in repr(study) + + +# ── Conformance: pass path ─────────────────────────────────────────────────── + + +def test_conformance_clean_passes(): + study = SubmissionPackage(datasets={"DM": _dm(), "AE": _ae(["S1-001", "S1-002"])}) + report = study.validate_conformance() + assert isinstance(report, ConformanceReport) + assert report.all_passed() + assert report.issues() == [] + assert report.n_datasets == 2 + summ = report.summary() + assert summ["AE"]["all_passed"] is True + assert summ["DM"]["n_steps"] > 0 + + +# ── Conformance: USUBJID referential integrity ─────────────────────────────── + + +def test_referential_integrity_flags_orphan_usubjid(): + study = SubmissionPackage(datasets={"DM": _dm(), "AE": _ae(["S1-001", "S1-999"])}) + report = study.validate_conformance() + assert not report.all_passed() + issues = report.issues() + assert any(i["dataset"] == "AE" for i in issues) + + # The failing step is the referential specially() check + ae = report["AE"] + ref_steps = [s for s in ae.validation_info if s.brief and "exist in DM" in s.brief] + assert len(ref_steps) == 1 + assert ref_steps[0].n_failed == 1 + + +def test_no_dm_means_no_referential_check(): + # Without DM there is no reference set, so no referential check is added. + study = SubmissionPackage(datasets={"AE": _ae(["S1-001", "S1-002"])}) + report = study.validate_conformance() + ae = report["AE"] + assert not any(s.brief and "exist in DM" in s.brief for s in ae.validation_info) + + +def test_polars_datasets_supported(): + study = SubmissionPackage( + datasets={ + "DM": pl.from_pandas(_dm()), + "AE": pl.from_pandas(_ae(["S1-001", "S1-999"])), + } + ) + report = study.validate_conformance() + assert not report.all_passed() + + +# ── Conformance: SUPP-- linkage ────────────────────────────────────────────── + + +def _suppae(idvarvals): + n = len(idvarvals) + return pd.DataFrame( + { + "STUDYID": ["S1"] * n, + "RDOMAIN": ["AE"] * n, + "USUBJID": ["S1-001"] * n, + "IDVAR": ["AESEQ"] * n, + "IDVARVAL": [str(v) for v in idvarvals], + "QNAM": ["AESOC"] * n, + "QLABEL": ["Body System"] * n, + "QVAL": ["x"] * n, + } + ) + + +def test_supp_idvar_resolution_pass(): + ae = _ae(["S1-001"]) # AESEQ == 1 + study = SubmissionPackage(datasets={"DM": _dm(), "AE": ae, "SUPPAE": _suppae([1])}) + report = study.validate_conformance() + supp = report["SUPPAE"] + idvar_steps = [s for s in supp.validation_info if s.brief and "IDVAR" in s.brief] + assert len(idvar_steps) == 1 + assert idvar_steps[0].n_failed == 0 + + +def test_supp_idvar_resolution_flags_dangling_link(): + ae = _ae(["S1-001"]) # AESEQ == 1 only + study = SubmissionPackage(datasets={"DM": _dm(), "AE": ae, "SUPPAE": _suppae([99])}) + report = study.validate_conformance() + supp = report["SUPPAE"] + idvar_steps = [s for s in supp.validation_info if s.brief and "IDVAR" in s.brief] + assert idvar_steps[0].n_failed == 1 + + +def test_supp_rdomain_must_be_present(): + supp = _suppae([1, 1]) # two rows + supp["RDOMAIN"] = "ZZ" # not a present domain + study = SubmissionPackage(datasets={"DM": _dm(), "AE": _ae(["S1-001"]), "SUPPAE": supp}) + report = study.validate_conformance() + supp_v = report["SUPPAE"] + rdom_steps = [s for s in supp_v.validation_info if s.brief and "RDOMAIN" in s.brief] + assert rdom_steps[0].n_failed == 2 + + +# ── Conformance: ADaM traceability ─────────────────────────────────────────── + + +def _adsl(usubjids=("S1-001", "S1-002")): + n = len(usubjids) + return pd.DataFrame( + { + "STUDYID": ["S1"] * n, + "USUBJID": list(usubjids), + "SUBJID": [u.split("-")[-1] for u in usubjids], + "ARM": ["Arm A", "Arm B"][:n], + "ARMCD": ["A", "B"][:n], + "TRT01P": ["Arm A", "Arm B"][:n], + "AGE": [40, 50][:n], + "SEX": ["M", "F"][:n], + "RACE": ["WHITE", "ASIAN"][:n], + "COUNTRY": ["USA", "USA"][:n], + "SAFFL": ["Y", "Y"][:n], + } + ) + + +def test_adam_adsl_traces_to_dm(): + # ADSL has a subject not in DM + study = SubmissionPackage( + datasets={"DM": _dm(["S1-001", "S1-002"]), "ADSL": _adsl(["S1-001", "S1-777"])}, + standard="adamig", + standard_version="1.1", + ) + report = study.validate_conformance() + adsl = report["ADSL"] + trace = [s for s in adsl.validation_info if s.brief and "trace to DM" in s.brief] + assert trace[0].n_failed == 1 + + +def test_adam_dataset_traces_to_adsl(): + adae = pd.DataFrame( + { + "STUDYID": ["S1"], + "USUBJID": ["S1-777"], # not in ADSL + "TRT01A": ["Arm A"], + "AESEQ": [1], + "AETERM": ["Headache"], + "TRTEMFL": ["Y"], + } + ) + study = SubmissionPackage( + datasets={"DM": _dm(), "ADSL": _adsl(["S1-001", "S1-002"]), "ADAE": adae}, + standard="adamig", + standard_version="1.1", + ) + report = study.validate_conformance() + adae_v = report["ADAE"] + trace = [s for s in adae_v.validation_info if s.brief and "trace to ADSL" in s.brief] + assert trace[0].n_failed == 1 + + +# ── cross_dataset=False disables the extra checks ──────────────────────────── + + +def test_cross_dataset_can_be_disabled(): + study = SubmissionPackage(datasets={"DM": _dm(), "AE": _ae(["S1-001", "S1-999"])}) + report = study.validate_conformance(cross_dataset=False) + ae = report["AE"] + assert not any(s.brief and "exist in DM" in s.brief for s in ae.validation_info) + + +# ── Report API ─────────────────────────────────────────────────────────────── + + +def test_report_issues_and_html(): + study = SubmissionPackage(datasets={"DM": _dm(), "AE": _ae(["S1-001", "S1-999"])}) + report = study.validate_conformance() + issues = report.issues() + assert all({"dataset", "step", "assertion", "n_failed"} <= set(i) for i in issues) + html = report._repr_html_() + assert "Conformance Report" in html + assert "AE" in html + assert "not-a-dataset".upper() not in html + # repr text + assert "ConformanceReport" in repr(report) + + +def test_report_agency_recorded(): + study = SubmissionPackage(datasets={"DM": _dm()}) + report = study.validate_conformance(agency="FDA") + assert report.agency == "FDA" + assert "FDA" in report._repr_html_() + + +# ── from_folder ingestion ──────────────────────────────────────────────────── + + +def test_from_folder_xpt_and_define_autodetect(): + src = pb.load_metadata_example("dm.xpt") + define_src = pb.load_metadata_example("define.xml") + d = Path(tempfile.mkdtemp()) + shutil.copy(src, d / "dm.xpt") + shutil.copy(define_src, d / "define.xml") + + study = SubmissionPackage.from_folder(d) + assert study.domains == ["DM"] + assert study.get_dataset("DM").shape[0] == 5 + # Define-XML auto-detected and lazily importable + assert study.define is not None + assert study.metadata is not None + + report = study.validate_conformance() + assert report.all_passed() + + +def test_from_folder_rejects_non_directory(): + with pytest.raises(NotADirectoryError): + SubmissionPackage.from_folder(pb.load_metadata_example("dm.xpt")) + + +def test_from_folder_dataset_json(): + # Dataset-JSON 1.1 columns/rows layout + doc = { + "datasetJSONVersion": "1.1.0", + "name": "DM", + "columns": [ + {"name": "STUDYID"}, + {"name": "DOMAIN"}, + {"name": "USUBJID"}, + {"name": "SUBJID"}, + {"name": "ARMCD"}, + {"name": "ARM"}, + {"name": "COUNTRY"}, + ], + "rows": [ + ["S1", "DM", "S1-001", "001", "A", "Arm A", "USA"], + ["S1", "DM", "S1-002", "002", "B", "Arm B", "USA"], + ], + } + d = Path(tempfile.mkdtemp()) + (d / "dm.json").write_text(json.dumps(doc)) + study = SubmissionPackage.from_folder(d) + assert study.domains == ["DM"] + assert study.get_dataset("DM").shape == (2, 7) + assert study.subject_ids("DM") == {"S1-001", "S1-002"} From 678f2e092dbbd32014d62bdf45577df16b7602c0 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:45:39 -0400 Subject: [PATCH 15/93] Update _cdisc_core.py --- pointblank/metadata/_cdisc_core.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pointblank/metadata/_cdisc_core.py b/pointblank/metadata/_cdisc_core.py index d8eaa35b8..2ebc8721c 100644 --- a/pointblank/metadata/_cdisc_core.py +++ b/pointblank/metadata/_cdisc_core.py @@ -1,4 +1,4 @@ -"""Parser for CDISC CORE engine JSON reports (PLAN_06 Phase 2, Path A). +"""Parser for CDISC CORE engine JSON reports. Pointblank wraps the open-source CDISC CORE engine (`cdisc-rules-engine`) as an external process: datasets and Define-XML are handed to CORE, it runs the authoritative conformance rule set, and its @@ -32,7 +32,8 @@ import os import shutil import subprocess -from dataclasses import dataclass, field as dataclass_field +from dataclasses import dataclass +from dataclasses import field as dataclass_field from pathlib import Path from typing import Any, Sequence From fa66c5d3a2269414e09ff7a692d03e85e8576f99 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:45:41 -0400 Subject: [PATCH 16/93] Update test_cdisc_core.py --- tests/test_cdisc_core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_cdisc_core.py b/tests/test_cdisc_core.py index 20a58fbe6..27994a3f1 100644 --- a/tests/test_cdisc_core.py +++ b/tests/test_cdisc_core.py @@ -1,4 +1,4 @@ -"""Tests for the CDISC CORE JSON report parser and CORE-backed ConformanceReport (PLAN_06 Phase 2). +"""Tests for the CDISC CORE JSON report parser and CORE-backed ConformanceReport. These tests run entirely against captured CORE report fixtures — no CORE engine dependency. The fixtures were produced by `core validate -s sdtmig -v 3-4 ... -of JSON` against CORE 0.16.0. @@ -40,7 +40,7 @@ def _load(name: str) -> dict: # A stand-in for the CORE CLI: copies a source JSON to `.` and records its argv to # `.argv.json`, so tests can drive the runner and assert the built command — no real CORE. -_FAKE_CORE_TEMPLATE = '''\ +_FAKE_CORE_TEMPLATE = """\ import json, shutil, sys args = sys.argv[1:] def opt(flag): @@ -52,7 +52,7 @@ def opt(flag): json.dump(args, f) shutil.copy({src!r}, stem + "." + ext) sys.exit({exit_code}) -''' +""" def _make_fake_core(tmp_path: Path, src_json: Path, exit_code: int = 0) -> Path: From d2a9d8c6c2fd1d5e1e8e32a44d9a2be6cc4decd6 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:45:43 -0400 Subject: [PATCH 17/93] Update test_metadata.py --- tests/test_metadata.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 94d9ec879..95b2300af 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -707,7 +707,7 @@ def test_validate_interrogate(self, spss_file): # ============================================================================= -# Phase 2: Frictionless + CSVW Tests +# Frictionless + CSVW Tests # ============================================================================= @@ -1473,7 +1473,7 @@ def test_invalid_json_raises(self, tmp_path): # ============================================================================= -# Phase 3: CDISC Define-XML Tests +# CDISC Define-XML Tests # ============================================================================= @@ -2024,7 +2024,7 @@ def test_import_ct_with_format(self, tmp_path): # ============================================================================= -# Phase 4: CDISC SDTM Domain Templates & Validation +# CDISC SDTM Domain Templates & Validation # ============================================================================= @@ -2520,7 +2520,7 @@ def test_interrogate_passes_valid_data(self): # ============================================================================= -# Phase 5: CDISC ADaM Templates & Validation +# CDISC ADaM Templates & Validation # ============================================================================= From eea38569563911068e25f691ba8dbe20243f2602 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:54:13 -0400 Subject: [PATCH 18/93] Add CORE submission validation helper --- pointblank/__init__.py | 2 + pointblank/metadata/__init__.py | 7 +- pointblank/metadata/_submission.py | 258 +++++++++++++++++++++++++++-- 3 files changed, 253 insertions(+), 14 deletions(-) diff --git a/pointblank/__init__.py b/pointblank/__init__.py index 6a0bdb699..a1ce64645 100644 --- a/pointblank/__init__.py +++ b/pointblank/__init__.py @@ -83,6 +83,7 @@ sdtm_to_metadata, validate_adam, validate_adam_structure, + validate_cdisc_submission, validate_sdtm, validate_sdtm_structure, ) @@ -222,4 +223,5 @@ # CDISC submission-package conformance "SubmissionPackage", "ConformanceReport", + "validate_cdisc_submission", ] diff --git a/pointblank/metadata/__init__.py b/pointblank/metadata/__init__.py index 677d33d9f..5719208eb 100644 --- a/pointblank/metadata/__init__.py +++ b/pointblank/metadata/__init__.py @@ -25,7 +25,11 @@ parse_core_report, ) from pointblank.metadata._sdtm_validate import sdtm_to_metadata, validate_sdtm -from pointblank.metadata._submission import ConformanceReport, SubmissionPackage +from pointblank.metadata._submission import ( + ConformanceReport, + SubmissionPackage, + validate_cdisc_submission, +) from pointblank.metadata._types import ( Codelist, CodelistEntry, @@ -61,6 +65,7 @@ "validate_adam", "SubmissionPackage", "ConformanceReport", + "validate_cdisc_submission", "CoreFinding", "CoreRuleResult", "CoreIssueSummary", diff --git a/pointblank/metadata/_submission.py b/pointblank/metadata/_submission.py index 39747334c..23475899b 100644 --- a/pointblank/metadata/_submission.py +++ b/pointblank/metadata/_submission.py @@ -15,7 +15,7 @@ from dataclasses import dataclass, field as dataclass_field from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Sequence if TYPE_CHECKING: from pointblank.metadata._cdisc_core import CoreFinding, CoreRuleResult, ParsedCoreReport @@ -25,6 +25,7 @@ __all__ = [ "SubmissionPackage", "ConformanceReport", + "validate_cdisc_submission", ] @@ -203,6 +204,9 @@ def __post_init__(self) -> None: # Normalize dataset keys to uppercase for consistent lookup. self.datasets = {str(k).upper(): v for k, v in self.datasets.items()} self._metadata: MetadataPackage | None = None + # Set by `from_folder`; lets the CORE engine read the on-disk datasets directly instead of + # re-materializing them. + self.source_folder: str | None = None # ── Construction ───────────────────────────────────────────────────────── @@ -269,7 +273,7 @@ def from_folder( if define is None and define_in_folder is not None: define = define_in_folder - return cls( + package = cls( datasets=datasets, define=define, ct_version=ct_version, @@ -277,6 +281,8 @@ def from_folder( standard_version=standard_version, study_id=study_id, ) + package.source_folder = str(folder) + return package # ── Dataset graph accessors ────────────────────────────────────────────── @@ -392,19 +398,28 @@ def orphan_ids(self, child: str, parent: str = "DM", column: str = "USUBJID") -> def validate_conformance( self, agency: str | None = None, + engine: str = "native", cross_dataset: bool = True, thresholds: Any = None, interrogate: bool = True, + *, + standard: str | None = None, + version: str | None = None, + controlled_terminology: str | Sequence[str] | None = None, + core: str | Sequence[str] | None = None, + core_cwd: str | Path | None = None, + cache: str | Path | None = None, + workdir: str | Path | None = None, ) -> ConformanceReport: """Validate CDISC conformance across the whole submission package. - For each dataset, this builds a Pointblank [`Validate`](`pointblank.Validate`) plan - combining: + Two engines are available: - - the existing single-dataset structural checks (via - [`validate_sdtm()`](`pointblank.validate_sdtm`) / - [`validate_adam()`](`pointblank.validate_adam`)), and - - cross-dataset conformance checks (when `cross_dataset=True`): + - **`"native"`** (default) — Pointblank's own checks. For each dataset this builds a + [`Validate`](`pointblank.Validate`) plan combining the single-dataset structural checks + (via [`validate_sdtm()`](`pointblank.validate_sdtm`) / + [`validate_adam()`](`pointblank.validate_adam`)) and cross-dataset conformance checks + (when `cross_dataset=True`): - **Referential integrity** — every `USUBJID` in a finding/events/interventions domain exists in DM. - **SUPP-- linkage** — `RDOMAIN` references a present domain, `USUBJID` exists in DM, @@ -414,25 +429,69 @@ def validate_conformance( - **ADaM ⇄ SDTM traceability** — `ADSL.USUBJID ⊆ DM.USUBJID`, and every other ADaM dataset's `USUBJID ⊆ ADSL.USUBJID`. + - **`"core"`** — hands the package to the external CDISC CORE engine + (`cdisc-rules-engine`), which runs the authoritative conformance rule set, and ingests + its results. Datasets are materialized to XPT (or the source folder is used directly for + folder-ingested packages), CORE is invoked as a subprocess, and its JSON report becomes a + CORE-form `ConformanceReport`. Requires an installed CORE executable (see `core`). + Parameters ---------- agency Optional agency rule-set selector (`"FDA"`, `"PMDA"`, or `None` for CDISC base rules). Recorded on the report; agency-specific business rule sets are a later phase, so this currently affects labeling only. + engine + `"native"` (default) or `"core"`. cross_dataset - Whether to add cross-dataset conformance checks. Defaults to `True`. + (Native only.) Whether to add cross-dataset conformance checks. Defaults to `True`. thresholds - Optional thresholds passed to each dataset's `Validate` (maps failing test units - onto Pointblank's warning/error/critical severity model). + (Native only.) Optional thresholds passed to each dataset's `Validate` (maps failing + test units onto Pointblank's warning/error/critical severity model). interrogate - Whether to interrogate (run) the validations before returning. Defaults to `True`. + (Native only.) Whether to interrogate (run) the validations before returning. + standard + (CORE only.) Override the CDISC standard sent to CORE. Defaults to the package's + `standard` (e.g., `"sdtmig"`). + version + (CORE only.) Override the standard version. Defaults to the package's + `standard_version` (e.g., `"3.4"`, sent to CORE hyphenated). + controlled_terminology + (CORE only.) CT package name(s) for CORE's `-ct` (e.g., `"sdtmct-2024-03-29"`). + core + (CORE only.) How to invoke CORE — a path/name to the CORE executable, a full command + prefix (e.g., `["python", "core.py"]`), or `None` to auto-discover via the + `POINTBLANK_CDISC_CORE` environment variable and then `PATH`. + core_cwd + (CORE only.) Working directory to run CORE from; required when invoking a repo checkout + (CORE resolves its bundled `resources/` relative to the current directory). + cache + (CORE only.) Path to CORE's rules cache directory (`-ca`). + workdir + (CORE only.) Directory for materialized XPT and the CORE report. If `None`, a temporary + directory is used and cleaned up. Returns ------- ConformanceReport - A report aggregating the per-dataset validations, keyed by dataset name. + A native-form report (per-dataset validations) or a CORE-form report, depending on + `engine`. """ + if engine not in ("native", "core"): + raise ValueError(f"engine must be 'native' or 'core', got {engine!r}.") + + if engine == "core": + return self._run_core_conformance( + agency=agency, + standard=standard, + version=version, + controlled_terminology=controlled_terminology, + core=core, + core_cwd=core_cwd, + cache=cache, + workdir=workdir, + ) + validations: dict[str, Validate] = {} for name in self.domains: @@ -447,6 +506,60 @@ def validate_conformance( return ConformanceReport(validations=validations, package=self, agency=agency) + def _run_core_conformance( + self, + agency: str | None, + standard: str | None, + version: str | None, + controlled_terminology: str | Sequence[str] | None, + core: str | Sequence[str] | None, + core_cwd: str | Path | None, + cache: str | Path | None, + workdir: str | Path | None, + ) -> ConformanceReport: + """Run the CDISC CORE engine over the package and ingest its report.""" + import tempfile + + from pointblank.metadata._cdisc_core import _CoreRunner, _materialize_datasets + + std = standard or self.standard + ver = version or self.standard_version + define_xml = self.define if isinstance(self.define, (str, Path)) else None + + runner = _CoreRunner(core=core, cwd=core_cwd) + + tmp: tempfile.TemporaryDirectory | None = None + if workdir is None: + tmp = tempfile.TemporaryDirectory(prefix="pb_cdisc_core_") + base = Path(tmp.name) + else: + base = Path(workdir) + base.mkdir(parents=True, exist_ok=True) + + try: + # Prefer reading the on-disk datasets directly for folder-ingested packages; otherwise + # materialize the in-memory datasets to XPT. + if self.source_folder and Path(self.source_folder).is_dir(): + data_dir: Path = Path(self.source_folder) + else: + data_dir = base / "data" + _materialize_datasets(self.datasets, data_dir) + + parsed = runner.validate_to_report( + data_dir=data_dir, + standard=std, + version=ver, + output_stem=base / "core_report", + define_xml=define_xml, + controlled_terminology=controlled_terminology, + cache=cache, + ) + finally: + if tmp is not None: + tmp.cleanup() + + return ConformanceReport.from_core_report(parsed, package=self, agency=agency) + def _build_dataset_validation( self, name: str, @@ -1062,3 +1175,122 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.__repr__() + + +# ── Module-level convenience entry point ────────────────────────────────────── + + +def validate_cdisc_submission( + source: str | Path | dict | SubmissionPackage, + standard: str | None = None, + version: str | None = None, + define: str | Path | Any | None = None, + controlled_terminology: str | Sequence[str] | None = None, + agency: str | None = None, + ct_version: str | None = None, + study_id: str | None = None, + core: str | Sequence[str] | None = None, + core_cwd: str | Path | None = None, + cache: str | Path | None = None, + workdir: str | Path | None = None, +) -> ConformanceReport: + """Validate a CDISC submission with the CDISC CORE engine, in one call. + + Convenience wrapper that builds a [`SubmissionPackage`](`pointblank.SubmissionPackage`) from + `source` and runs [`validate_conformance()`](`pointblank.SubmissionPackage`) with + `engine="core"`. Requires an installed CORE executable (see `core`). + + Parameters + ---------- + source + The submission to validate. One of: a path to a folder of datasets (XPT / Dataset-JSON, + with an optional `define.xml`), a mapping of dataset name to DataFrame, or an already-built + `SubmissionPackage`. + standard + The CDISC standard (e.g., `"sdtmig"`). Defaults to `"sdtmig"` (or the package's `standard` + when `source` is a `SubmissionPackage`). + version + The standard version (e.g., `"3.4"`). Defaults to `"3.4"` (or the package's value). + define + Optional Define-XML path (ignored when `source` is a `SubmissionPackage` — set it on the + package instead). Auto-detected from a folder `source` when present. + controlled_terminology + CT package name(s) for CORE's `-ct` (e.g., `"sdtmct-2024-03-29"`). + agency + Optional agency rule-set selector recorded on the report. + ct_version + Optional Controlled Terminology version pin recorded on the package. + study_id + Optional study identifier. + core + How to invoke CORE — a path/name to the CORE executable, a full command prefix (e.g., + `["python", "core.py"]`), or `None` to auto-discover via the `POINTBLANK_CDISC_CORE` + environment variable and then `PATH`. + core_cwd + Working directory to run CORE from; required when invoking a repo checkout. + cache + Path to CORE's rules cache directory (`-ca`). + workdir + Directory for materialized XPT and the CORE report. If `None`, a temporary directory is used. + + Returns + ------- + ConformanceReport + A CORE-form report (`is_core` is `True`). + + Examples + -------- + ```python + import pointblank as pb + + report = pb.validate_cdisc_submission( + "study_xyz/sdtm/", + standard="sdtmig", + version="3.4", + agency="FDA", + ) + report.summary() + ``` + """ + if isinstance(source, SubmissionPackage): + package = source + elif isinstance(source, dict): + package = SubmissionPackage( + datasets=source, + define=define, + standard=standard or "sdtmig", + standard_version=version or "3.4", + ct_version=ct_version, + study_id=study_id, + ) + elif isinstance(source, (str, Path)): + folder = Path(source) + if not folder.is_dir(): + raise NotADirectoryError( + f"Expected a folder of datasets, but '{folder}' is not a directory." + ) + package = SubmissionPackage.from_folder( + folder, + define=define, + standard=standard or "sdtmig", + standard_version=version or "3.4", + ct_version=ct_version, + study_id=study_id, + ) + else: + raise TypeError( + "source must be a folder path, a {name: DataFrame} mapping, or a SubmissionPackage; " + f"got {type(source).__name__}." + ) + + return package.validate_conformance( + engine="core", + agency=agency, + standard=standard, + version=version, + controlled_terminology=controlled_terminology, + core=core, + core_cwd=core_cwd, + cache=cache, + workdir=workdir, + ) From 6586fb55127a5ac7031e0fc3d5800d8c4df2eeb8 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:54:15 -0400 Subject: [PATCH 19/93] Update great-docs.yml --- great-docs.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/great-docs.yml b/great-docs.yml index eee444036..f86dcb3a1 100644 --- a/great-docs.yml +++ b/great-docs.yml @@ -414,10 +414,12 @@ reference: Validate an entire study submission package for CDISC conformance, spanning all datasets. Use `SubmissionPackage` to model a study as a graph of related datasets (with optional Define-XML and Controlled Terminology context) and `SubmissionPackage.validate_conformance()` - to run single-dataset structural checks plus cross-dataset checks (USUBJID referential - integrity, SUPP-- linkage, RELREC, and ADaM ⇄ SDTM traceability). Results are returned as a - `ConformanceReport`. + to run native single-dataset structural checks plus cross-dataset checks (USUBJID referential + integrity, SUPP-- linkage, RELREC, and ADaM ⇄ SDTM traceability). With `engine="core"` (or the + `validate_cdisc_submission()` shortcut) the package is handed to the external CDISC CORE engine + for the authoritative rule set. Results are returned as a `ConformanceReport`. contents: + - validate_cdisc_submission - name: SubmissionPackage members: true - name: ConformanceReport From 600952d07b96bdc419d94e385862b35e8773780f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:55:45 -0400 Subject: [PATCH 20/93] Add cdisc-core optional dependency extra --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index a9ee0fb34..9f2eb327d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,11 @@ otel = [ excel = ["openpyxl>=3.0.0"] cdisc = ["lxml>=4.9.0"] +# For the CDISC CORE conformance engine wrapper (`validate_cdisc_submission`, +# `SubmissionPackage.validate_conformance(engine="core")`): pyreadstat materializes in-memory +# datasets to XPT for CORE. The CORE engine itself is an externally-installed executable / Docker +# image, discovered at runtime and deliberately not a Python dependency of Pointblank. +cdisc-core = ["pyreadstat>=1.2.0"] bigquery = ["ibis-framework[bigquery]>=9.5.0"] databricks = ["ibis-framework[databricks]>=9.5.0"] duckdb = ["ibis-framework[duckdb]>=9.5.0"] From af57826f26d40f83267ee205098dda1a737843d4 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:55:50 -0400 Subject: [PATCH 21/93] Update test_cdisc_core.py --- tests/test_cdisc_core.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_cdisc_core.py b/tests/test_cdisc_core.py index 27994a3f1..f74c19146 100644 --- a/tests/test_cdisc_core.py +++ b/tests/test_cdisc_core.py @@ -67,6 +67,12 @@ def _fake_runner(tmp_path: Path, exit_code: int = 0) -> _CoreRunner: return _CoreRunner(core=[sys.executable, str(script)]) +def _fake_core_cmd(tmp_path: Path) -> list: + """A `core=` command list (usable with validate_conformance) backed by the fake CORE.""" + script = _make_fake_core(tmp_path, _FIXTURES / "core_report_trimmed.json") + return [sys.executable, str(script)] + + @pytest.fixture def full_report() -> dict: return _load("core_report_full.json") From ebf9cf54ac9a827f388a7b99f319ba15ddd56b15 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 7 Jul 2026 15:56:02 -0400 Subject: [PATCH 22/93] Add CORE conformance wiring tests --- tests/test_cdisc_core.py | 133 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/tests/test_cdisc_core.py b/tests/test_cdisc_core.py index f74c19146..9e410e86a 100644 --- a/tests/test_cdisc_core.py +++ b/tests/test_cdisc_core.py @@ -511,3 +511,136 @@ def test_runner_launch_failure_raises(tmp_path): version="3.4", output_stem=tmp_path / "out", ) + + +# ── validate_conformance(engine="core") wiring ───────────────────────────────── + + +def _dm_pkg(): + import pointblank as pb + + return pb.SubmissionPackage( + datasets={"DM": _dm_df()}, standard="sdtmig", standard_version="3.4" + ) + + +def test_validate_conformance_core_materializes_in_memory(tmp_path): + pytest.importorskip("pyreadstat") + rep = _dm_pkg().validate_conformance( + engine="core", agency="FDA", core=_fake_core_cmd(tmp_path), workdir=tmp_path / "w" + ) + assert rep.is_core is True + assert rep.agency == "FDA" + assert rep.summary()["standard"] == "SDTMIG" + # in-memory datasets were materialized to XPT for CORE + assert (tmp_path / "w" / "data" / "dm.xpt").exists() + + +def test_validate_conformance_core_version_hyphenated(tmp_path): + pytest.importorskip("pyreadstat") + _dm_pkg().validate_conformance( + engine="core", core=_fake_core_cmd(tmp_path), workdir=tmp_path / "w" + ) + argv = json.loads((tmp_path / "w" / "core_report.argv.json").read_text()) + assert argv[argv.index("-v") + 1] == "3-4" + assert argv[argv.index("-s") + 1] == "sdtmig" + + +def test_validate_conformance_core_folder_passthrough(tmp_path): + import shutil + + import pointblank as pb + + folder = tmp_path / "study" + folder.mkdir() + shutil.copy(pb.load_metadata_example("dm.xpt"), folder / "dm.xpt") + shutil.copy(pb.load_metadata_example("define.xml"), folder / "define.xml") + + pkg = pb.SubmissionPackage.from_folder(folder) + assert pkg.source_folder == str(folder) + + rep = pkg.validate_conformance( + engine="core", core=_fake_core_cmd(tmp_path), workdir=tmp_path / "w" + ) + assert rep.is_core + argv = json.loads((tmp_path / "w" / "core_report.argv.json").read_text()) + # CORE was pointed at the source folder directly, with define via -dxp + assert argv[argv.index("-d") + 1] == str(folder) + assert "-dxp" in argv + + +def test_validate_conformance_rejects_bad_engine(): + with pytest.raises(ValueError): + _dm_pkg().validate_conformance(engine="bogus") + + +def test_validate_conformance_native_default_unchanged(): + # engine defaults to native and yields a native report + rep = _dm_pkg().validate_conformance() + assert rep.is_core is False + + +# ── validate_cdisc_submission() convenience entry point ───────────────────────── + + +def test_validate_cdisc_submission_from_dict(tmp_path): + pytest.importorskip("pyreadstat") + import pointblank as pb + + rep = pb.validate_cdisc_submission( + {"DM": _dm_df()}, + standard="sdtmig", + version="3.4", + core=_fake_core_cmd(tmp_path), + workdir=tmp_path / "w", + ) + assert rep.is_core + assert rep.summary()["n_rules"] == 12 + + +def test_validate_cdisc_submission_from_folder(tmp_path): + import shutil + + import pointblank as pb + + folder = tmp_path / "study" + folder.mkdir() + shutil.copy(pb.load_metadata_example("dm.xpt"), folder / "dm.xpt") + + rep = pb.validate_cdisc_submission( + folder, core=_fake_core_cmd(tmp_path), workdir=tmp_path / "w" + ) + assert rep.is_core + + +def test_validate_cdisc_submission_from_package(tmp_path): + pytest.importorskip("pyreadstat") + import pointblank as pb + + pkg = _dm_pkg() + rep = pb.validate_cdisc_submission( + pkg, core=_fake_core_cmd(tmp_path), workdir=tmp_path / "w" + ) + assert rep.is_core + assert rep.package is pkg + + +def test_validate_cdisc_submission_bad_type(): + import pointblank as pb + + with pytest.raises(TypeError): + pb.validate_cdisc_submission(12345) + + +def test_validate_cdisc_submission_nonexistent_folder(): + import pointblank as pb + + with pytest.raises(NotADirectoryError): + pb.validate_cdisc_submission("/no/such/folder/here") + + +def test_validate_cdisc_submission_exported(): + import pointblank as pb + + assert pb.validate_cdisc_submission is not None + assert "validate_cdisc_submission" in pb.__all__ From c1d64ad81587141bc52970e3ae7c991019cff03a Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 14:55:24 -0400 Subject: [PATCH 23/93] Add JSON and Excel export for reports --- pointblank/metadata/_submission.py | 172 +++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/pointblank/metadata/_submission.py b/pointblank/metadata/_submission.py index 23475899b..68f951e48 100644 --- a/pointblank/metadata/_submission.py +++ b/pointblank/metadata/_submission.py @@ -1111,6 +1111,178 @@ def n_datasets(self) -> int: return len(self.core.datasets) return len(self.validations) + def to_json(self, path: str | Path) -> Path: + """Save the conformance report as a JSON file. + + For CORE reports the output mirrors the original CORE JSON structure (`Conformance_Details`, + `Dataset_Details`, `Issue_Summary`, `Issue_Details`, `Rules_Report`), making the file + readable by anything that parses a standard CORE report. For native reports the file + contains `summary` and `issues` keys. + + Parameters + ---------- + path + Destination path (including filename). Parent directories are created if needed. + + Returns + ------- + Path + The path written. + """ + import json + + dest = Path(path) + dest.parent.mkdir(parents=True, exist_ok=True) + + if self.is_core: + core = self.core + data: dict = { + "Conformance_Details": core.details, + "Dataset_Details": core.datasets, + "Issue_Summary": [ + { + "dataset": s.dataset, + "core_id": s.rule_id, + "message": s.message, + "issues": s.issues, + } + for s in core.issue_summary + ], + "Issue_Details": [ + { + "core_id": f.rule_id, + "message": f.message, + "dataset": f.dataset, + "executability": f.executability or "", + "USUBJID": f.usubjid or "", + "row": f.row, + "SEQ": f.seq or "", + "variables": f.variables, + "values": f.values, + } + for f in core.findings + ], + "Rules_Report": [ + { + "core_id": r.rule_id, + "status": r.status, + "message": r.message or "", + "version": r.version or "", + "cdisc_rule_id": r.cdisc_rule_id or "", + "fda_rule_id": r.fda_rule_id or "", + } + for r in core.rules + ], + } + else: + data = {"summary": self.summary(), "issues": self.issues()} + + with open(dest, "w") as f: + json.dump(data, f, indent=2, default=str) + return dest + + def to_excel(self, path: str | Path) -> Path: + """Save the conformance report as an Excel workbook. + + For CORE reports the workbook contains sheets `Issue_Summary`, `Issue_Details`, + `Rules_Report`, and `Conformance_Details`. For native reports the workbook contains + `Issues` and `Summary`. + + Requires the `openpyxl` package (`pip install openpyxl` or + `pip install 'pointblank[excel]'`). + + Parameters + ---------- + path + Destination path (including filename). Parent directories are created if needed. + + Returns + ------- + Path + The path written. + + Raises + ------ + ImportError + If `openpyxl` or `pandas` are not installed. + """ + try: + import openpyxl # noqa: F401 + except ImportError: + raise ImportError( + "The 'openpyxl' package is required to export to Excel. " + "Install it with: pip install openpyxl" + ) from None + try: + import pandas as pd + except ImportError: + raise ImportError( + "The 'pandas' package is required to export to Excel. " + "Install it with: pip install pandas" + ) from None + + dest = Path(path) + dest.parent.mkdir(parents=True, exist_ok=True) + + with pd.ExcelWriter(dest, engine="openpyxl") as writer: + if self.is_core: + core = self.core + if core.issue_summary: + pd.DataFrame( + [ + { + "Dataset": s.dataset, + "Rule ID": s.rule_id, + "Message": s.message, + "Issues": s.issues, + } + for s in core.issue_summary + ] + ).to_excel(writer, sheet_name="Issue_Summary", index=False) + if core.findings: + pd.DataFrame( + [ + { + "Rule ID": f.rule_id, + "Message": f.message, + "Dataset": f.dataset, + "USUBJID": f.usubjid or "", + "Row": f.row, + "SEQ": f.seq or "", + "Variables": ", ".join(str(v) for v in f.variables), + "Values": ", ".join(str(v) for v in f.values), + } + for f in core.findings + ] + ).to_excel(writer, sheet_name="Issue_Details", index=False) + if core.rules: + pd.DataFrame( + [ + { + "Rule ID": r.rule_id, + "Status": r.status, + "Message": r.message or "", + "Version": r.version or "", + "CDISC Rule ID": r.cdisc_rule_id or "", + "FDA Rule ID": r.fda_rule_id or "", + } + for r in core.rules + ] + ).to_excel(writer, sheet_name="Rules_Report", index=False) + if core.details: + pd.DataFrame( + [{"Key": k, "Value": v} for k, v in core.details.items()] + ).to_excel(writer, sheet_name="Conformance_Details", index=False) + else: + issues = self.issues() + if issues: + pd.DataFrame(issues).to_excel(writer, sheet_name="Issues", index=False) + pd.DataFrame( + [{"Dataset": name, **s} for name, s in self.summary().items()] + ).to_excel(writer, sheet_name="Summary", index=False) + + return dest + def _repr_html_(self) -> str: agency = f" — agency: {self.agency}" if self.agency else "" From c6eeed209fac03026c1bbcdc38a628970f630251 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 15:23:47 -0400 Subject: [PATCH 24/93] Add ConformanceReport export tests --- tests/test_cdisc_core.py | 117 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/test_cdisc_core.py b/tests/test_cdisc_core.py index 9e410e86a..cfb12758b 100644 --- a/tests/test_cdisc_core.py +++ b/tests/test_cdisc_core.py @@ -644,3 +644,120 @@ def test_validate_cdisc_submission_exported(): assert pb.validate_cdisc_submission is not None assert "validate_cdisc_submission" in pb.__all__ + + +# ── ConformanceReport export methods ────────────────────────────────────────── + + +def test_to_json_core_structure(tmp_path, full_report): + rep = ConformanceReport.from_core_report(full_report) + dest = rep.to_json(tmp_path / "report.json") + assert dest.exists() + import json + + data = json.loads(dest.read_text()) + assert "Conformance_Details" in data + assert "Issue_Summary" in data + assert "Issue_Details" in data + assert "Rules_Report" in data + assert len(data["Rules_Report"]) == 430 + assert all("core_id" in r for r in data["Rules_Report"]) + + +def test_to_json_core_roundtrips_through_parser(tmp_path, full_report): + rep = ConformanceReport.from_core_report(full_report) + dest = rep.to_json(tmp_path / "report.json") + import json + + from pointblank.metadata import parse_core_report + + reparsed = parse_core_report(json.loads(dest.read_text())) + assert reparsed.standard == rep.core.standard + assert len(reparsed.rules) == len(rep.core.rules) + assert reparsed.n_total_issues == rep.core.n_total_issues + + +def test_to_json_core_creates_parent_dirs(tmp_path, trimmed_report): + rep = ConformanceReport.from_core_report(trimmed_report) + dest = rep.to_json(tmp_path / "nested" / "dir" / "report.json") + assert dest.exists() + + +def test_to_json_native(tmp_path): + import json + + import pandas as pd + + import pointblank as pb + + dm = pd.DataFrame( + {"STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["S1-001"], + "SUBJID": ["001"], "ARMCD": ["A"], "ARM": ["A"], "COUNTRY": ["USA"]} + ) + rep = pb.SubmissionPackage(datasets={"DM": dm}).validate_conformance() + dest = rep.to_json(tmp_path / "native_report.json") + assert dest.exists() + data = json.loads(dest.read_text()) + assert "summary" in data + assert "issues" in data + assert "DM" in data["summary"] + + +def test_to_excel_core_sheets(tmp_path, full_report): + pytest.importorskip("openpyxl") + import openpyxl + + rep = ConformanceReport.from_core_report(full_report) + dest = rep.to_excel(tmp_path / "report.xlsx") + assert dest.exists() + wb = openpyxl.load_workbook(dest) + assert "Issue_Summary" in wb.sheetnames + assert "Issue_Details" in wb.sheetnames + assert "Rules_Report" in wb.sheetnames + assert "Conformance_Details" in wb.sheetnames + + +def test_to_excel_core_row_counts(tmp_path, full_report): + pytest.importorskip("openpyxl") + import openpyxl + + rep = ConformanceReport.from_core_report(full_report) + dest = rep.to_excel(tmp_path / "report.xlsx") + wb = openpyxl.load_workbook(dest) + # Rules_Report sheet: 1 header row + 430 data rows + rules_rows = wb["Rules_Report"].max_row + assert rules_rows == 431 + + +def test_to_excel_native(tmp_path): + pytest.importorskip("openpyxl") + import openpyxl + import pandas as pd + + import pointblank as pb + + dm = pd.DataFrame( + {"STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["S1-001"], + "SUBJID": ["001"], "ARMCD": ["A"], "ARM": ["A"], "COUNTRY": ["USA"]} + ) + rep = pb.SubmissionPackage(datasets={"DM": dm}).validate_conformance() + dest = rep.to_excel(tmp_path / "native_report.xlsx") + assert dest.exists() + wb = openpyxl.load_workbook(dest) + assert "Summary" in wb.sheetnames + + +def test_to_excel_missing_openpyxl(tmp_path, full_report, monkeypatch): + import builtins + + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "openpyxl": + raise ImportError("mocked missing openpyxl") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + rep = ConformanceReport.from_core_report(full_report) + with pytest.raises(ImportError, match="openpyxl"): + rep.to_excel(tmp_path / "report.xlsx") From c76665bc5e7d2b1963245c58f90a0a0234a4c24f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 15:27:59 -0400 Subject: [PATCH 25/93] Add pytest marker for CDISC CORE tests --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 9f2eb327d..2f55358c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,6 +143,7 @@ addopts = "-ra --cov=pointblank" testpaths = ["tests"] markers = [ "otel: OpenTelemetry integration tests", + "cdisc_core: Tests that require the CDISC CORE engine to be installed and discoverable (auto-skipped when CORE is absent).", ] [tool.ruff] From 13b771d584e17a937a20c0486d8a5d194042bcd2 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 15:28:11 -0400 Subject: [PATCH 26/93] Skip CDISC CORE tests when unavailable --- tests/conftest.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index b3e828318..8224559b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,28 @@ import pytest +def pytest_collection_modifyitems(config, items): + """Auto-skip tests marked @pytest.mark.cdisc_core when CORE is not discoverable.""" + try: + from pointblank.metadata._cdisc_core import CoreNotFoundError, _resolve_core_command + + _resolve_core_command(None) + core_available = True + except Exception: + core_available = False + + if not core_available: + skip = pytest.mark.skip( + reason=( + "CDISC CORE engine not found: set the POINTBLANK_CDISC_CORE env var to the " + "CORE executable path or install the CORE standalone binary on PATH." + ) + ) + for item in items: + if "cdisc_core" in item.keywords: + item.add_marker(skip) + + @pytest.fixture def half_null_ser(): """A 1k element half null series. Exists to get around rounding issues.""" From 585c3cff8b1a275e1929c1702bb388fbc1d19972 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 15:28:24 -0400 Subject: [PATCH 27/93] Add CDISC CORE integration tests --- tests/test_cdisc_core_integration.py | 220 +++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 tests/test_cdisc_core_integration.py diff --git a/tests/test_cdisc_core_integration.py b/tests/test_cdisc_core_integration.py new file mode 100644 index 000000000..98ed850f9 --- /dev/null +++ b/tests/test_cdisc_core_integration.py @@ -0,0 +1,220 @@ +"""Real end-to-end CDISC CORE engine integration tests. + +These tests require the CDISC CORE engine to be installed and discoverable. They are auto-skipped +when CORE is absent (see the `pytest_collection_modifyitems` hook in `conftest.py`). + +How to run: + # Standalone executable on PATH (named 'core' or 'cdisc-rules-engine'): + pytest -m cdisc_core + + # Explicit path / command via env var (supports repo-checkout invocations): + POINTBLANK_CDISC_CORE="python /path/to/cdisc-rules-engine/core.py" \\ + POINTBLANK_CDISC_CORE_CWD="/path/to/cdisc-rules-engine" \\ + pytest -m cdisc_core + + # Optional: cache directory for the rules cache: + POINTBLANK_CDISC_CORE_CACHE="/path/to/cdisc-rules-engine/resources/cache" \\ + pytest -m cdisc_core + +These env vars are honoured automatically by pointblank (POINTBLANK_CDISC_CORE) and by this test +module (POINTBLANK_CDISC_CORE_CWD, POINTBLANK_CDISC_CORE_CACHE) so no command-line options are +needed. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pandas as pd +import pytest + +import pointblank as pb +from pointblank.metadata import ConformanceReport, CoreFinding, CoreRuleResult, parse_core_report + +pytestmark = pytest.mark.cdisc_core + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +# Optional env vars that tell the integration tests how to invoke a repo-checkout CORE. +_CORE_CWD = os.environ.get("POINTBLANK_CDISC_CORE_CWD") +_CORE_CACHE = os.environ.get("POINTBLANK_CDISC_CORE_CACHE") + + +def _run_kwargs() -> dict: + """Extra kwargs forwarded to validate_cdisc_submission / validate_conformance.""" + kw = {} + if _CORE_CWD: + kw["core_cwd"] = _CORE_CWD + if _CORE_CACHE: + kw["cache"] = _CORE_CACHE + return kw + + +def _minimal_dm() -> pd.DataFrame: + return pd.DataFrame( + { + "STUDYID": ["STUDY01"] * 3, + "DOMAIN": ["DM"] * 3, + "USUBJID": ["STUDY01-001", "STUDY01-002", "STUDY01-003"], + "SUBJID": ["001", "002", "003"], + "RFSTDTC": ["2024-01-01", "2024-01-02", "2024-01-03"], + "ARMCD": ["A", "B", "A"], + "ARM": ["Arm A", "Arm B", "Arm A"], + "SEX": ["M", "F", "M"], + "RACE": ["WHITE", "ASIAN", "BLACK OR AFRICAN AMERICAN"], + "COUNTRY": ["USA", "USA", "USA"], + "DMDTC": ["2024-01-01", "2024-01-02", "2024-01-03"], + } + ) + + +@pytest.fixture(scope="module") +def core_report(tmp_path_factory) -> ConformanceReport: + """Run the real CORE engine once per test-module and cache the result.""" + tmp = tmp_path_factory.mktemp("cdisc_core_integration") + return pb.validate_cdisc_submission( + {"DM": _minimal_dm()}, + standard="sdtmig", + version="3.4", + workdir=tmp / "workdir", + **_run_kwargs(), + ) + + +# ── Basic shape and provenance ───────────────────────────────────────────────── + + +def test_report_is_core_form(core_report): + assert core_report.is_core is True + + +def test_report_has_many_rules(core_report): + # A healthy SDTMIG 3.4 run covers hundreds of rules. + assert len(core_report.rules()) >= 100 + + +def test_report_summary_keys_and_types(core_report): + s = core_report.summary() + assert isinstance(s["standard"], str) + assert isinstance(s["version"], str) + assert isinstance(s["engine_version"], str) + assert isinstance(s["n_rules"], int) and s["n_rules"] >= 100 + assert isinstance(s["n_issues"], int) + assert isinstance(s["status_counts"], dict) + assert isinstance(s["all_passed"], bool) + + +def test_report_standard_version_recorded(core_report): + s = core_report.summary() + # CORE uppercases the standard and prepends "V" to the version. + assert "SDTMIG" in s["standard"].upper() + assert "3" in s["version"] + + +# ── Accessors ────────────────────────────────────────────────────────────────── + + +def test_issues_returns_list_of_dicts(core_report): + issues = core_report.issues() + assert isinstance(issues, list) + if issues: + assert all({"dataset", "rule_id", "message", "issues", "status"} <= set(i) for i in issues) + + +def test_findings_returns_core_finding_objects(core_report): + findings = core_report.findings() + assert isinstance(findings, list) + if findings: + assert all(isinstance(f, CoreFinding) for f in findings) + assert all(f.rule_id and f.dataset for f in findings) + + +def test_rules_returns_core_rule_result_objects(core_report): + all_rules = core_report.rules() + assert all(isinstance(r, CoreRuleResult) for r in all_rules) + # Every rule has a status string + assert all(isinstance(r.status, str) and r.status for r in all_rules) + + +def test_rules_status_filter(core_report): + from pointblank.metadata._cdisc_core import STATUS_SKIPPED, STATUS_SUCCESS + + success = core_report.rules(status=STATUS_SUCCESS) + skipped = core_report.rules(status=STATUS_SKIPPED) + assert all(r.status == STATUS_SUCCESS for r in success) + assert all(r.status == STATUS_SKIPPED for r in skipped) + # Together they should cover most of the rule set (a minimal DM won't fail everything) + assert len(success) + len(skipped) > 0 + + +def test_all_passed_is_bool(core_report): + # For a minimal one-dataset DM, all_passed is either True or False (real run) + assert isinstance(core_report.all_passed(), bool) + + +# ── Export methods ───────────────────────────────────────────────────────────── + + +def test_to_json_round_trips(core_report, tmp_path): + dest = core_report.to_json(tmp_path / "report.json") + assert dest.exists() and dest.stat().st_size > 0 + + data = json.loads(dest.read_text()) + reparsed = parse_core_report(data) + assert reparsed.standard == core_report.core.standard + assert len(reparsed.rules) == len(core_report.core.rules) + assert reparsed.n_total_issues == core_report.core.n_total_issues + + +def test_to_excel_sheets(core_report, tmp_path): + openpyxl = pytest.importorskip("openpyxl") + dest = core_report.to_excel(tmp_path / "report.xlsx") + assert dest.exists() and dest.stat().st_size > 0 + wb = openpyxl.load_workbook(dest) + assert "Rules_Report" in wb.sheetnames + assert "Conformance_Details" in wb.sheetnames + # Row count: header + one row per rule + n_data_rows = wb["Rules_Report"].max_row - 1 + assert n_data_rows == len(core_report.core.rules) + + +# ── from_folder passthrough ──────────────────────────────────────────────────── + + +def test_from_folder_with_core(tmp_path): + """CORE reads existing XPT files directly when the package was ingested from a folder.""" + pytest.importorskip("pyreadstat") + import shutil + + folder = tmp_path / "study" + folder.mkdir() + shutil.copy(pb.load_metadata_example("dm.xpt"), folder / "dm.xpt") + + pkg = pb.SubmissionPackage.from_folder(folder) + assert pkg.source_folder == str(folder) + + rep = pkg.validate_conformance( + engine="core", + standard="sdtmig", + version="3.4", + workdir=tmp_path / "w", + **_run_kwargs(), + ) + assert rep.is_core is True + assert len(rep.rules()) >= 100 + + +# ── validate_cdisc_submission shortcuts ──────────────────────────────────────── + + +def test_validate_cdisc_submission_accepts_package(tmp_path): + pkg = pb.SubmissionPackage( + datasets={"DM": _minimal_dm()}, + standard="sdtmig", + standard_version="3.4", + ) + rep = pb.validate_cdisc_submission(pkg, workdir=tmp_path / "w", **_run_kwargs()) + assert rep.is_core is True + assert rep.package is pkg From 6a73a8a2be9c2c4a450885b3a31e4fea0c3c9f25 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 15:43:05 -0400 Subject: [PATCH 28/93] Add CDISC submission conformance guide --- .../04-cdisc-submission-conformance.qmd | 729 ++++++++++++++++++ 1 file changed, 729 insertions(+) create mode 100644 user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd new file mode 100644 index 000000000..05f7230f2 --- /dev/null +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -0,0 +1,729 @@ +--- +title: CDISC Submission Conformance +jupyter: python3 +html-table-processing: none +--- + +```{python} +#| echo: false +#| output: false +import pointblank as pb +pb.config(report_incl_footer_timings=False) +``` + +Regulatory submissions require that every dataset in a study package passes CDISC conformance +rules, not just individual structural checks on each domain in isolation. The submission as a +whole must be internally consistent: every subject in an Adverse Events domain must appear in +Demographics, every Supplemental Qualifier must resolve to a record in its parent domain, every +ADaM dataset must trace back to ADSL, and several hundred rule engine checks must clear before +FDA or PMDA will accept the package. + +Pointblank addresses this through the `SubmissionPackage` model, which treats the entire study +as a graph of related datasets and runs conformance checks across that graph. Two validation +engines are available: + +- **Native**: Pointblank's own cross-dataset checks for referential integrity, SUPP-- linkage, + RELREC resolution, and ADaM traceability. No external dependencies, runs in-process. + +- **CORE**: Delegates to the open-source + [CDISC CORE engine](https://github.com/cdisc-org/cdisc-rules-engine), which runs the + authoritative CDISC conformance rule set (430+ rules for SDTMIG 3.4). Requires the CORE + executable to be installed separately; Pointblank invokes it as a subprocess and ingests its + results. + +You can use both in the same workflow: native checks for fast feedback during development, CORE +for the final pre-submission gate. + +## Prerequisites + +The `SubmissionPackage` and `ConformanceReport` classes require no additional dependencies +beyond Pointblank itself. If you want to: + +- Read XPT files from a folder with `from_folder()`: install `pyreadstat` +- Materialize in-memory DataFrames to XPT for the CORE engine: install `pyreadstat` +- Export a report to Excel with `to_excel()`: install `openpyxl` + +```bash +pip install pointblank[cdisc-core] # adds pyreadstat +pip install pointblank[excel] # adds openpyxl +``` + +The CORE engine itself is not a Python dependency of Pointblank and must be installed +separately. See [Installing the CDISC CORE Engine](#installing-the-cdisc-core-engine) below. + +## Native Cross-Dataset Validation + +### Building a Submission Package + +A `SubmissionPackage` groups the datasets of a study and understands the relationships between +them. You construct one from a dictionary of domain names to DataFrames: + +```{python} +import polars as pl +import pointblank as pb + +dm = pl.DataFrame({ + "STUDYID": ["STUDY01"] * 4, + "DOMAIN": ["DM"] * 4, + "USUBJID": ["STUDY01-001", "STUDY01-002", "STUDY01-003", "STUDY01-004"], + "SUBJID": ["001", "002", "003", "004"], + "ARMCD": ["A", "B", "A", "B"], + "ARM": ["Arm A", "Arm B", "Arm A", "Arm B"], + "SEX": ["M", "F", "M", "F"], + "RACE": ["WHITE", "ASIAN", "WHITE", "BLACK OR AFRICAN AMERICAN"], + "COUNTRY": ["USA"] * 4, +}) + +ae = pl.DataFrame({ + "STUDYID": ["STUDY01"] * 3, + "DOMAIN": ["AE"] * 3, + "USUBJID": ["STUDY01-001", "STUDY01-002", "STUDY01-003"], + "AESEQ": [1, 1, 1], + "AETERM": ["Headache", "Nausea", "Dizziness"], + "AESEV": ["MILD", "MODERATE", "MILD"], +}) + +study = pb.SubmissionPackage( + datasets={"DM": dm, "AE": ae}, + standard="sdtmig", + standard_version="3.4", + study_id="STUDY01", +) + +print(study) +``` + +Dataset names are normalized to uppercase internally. You can access datasets by name and +query the subject graph: + +```{python} +print("Domains:", study.domains) +print("Subjects in DM:", study.subject_ids("DM")) + +# Check whether a domain is present +print("Has AE:", "AE" in study) +print("Has LB:", "LB" in study) + +# Find subjects in AE that are not in DM (should be empty for clean data) +print("Orphan USUBJIDs:", study.orphan_ids("AE", parent="DM")) +``` + +### Running Native Conformance Validation + +Calling `validate_conformance()` without any arguments runs the native engine. For each domain +it builds a `Validate` plan combining SDTM structural checks with cross-dataset consistency +checks, then interrogates them all at once: + +```{python} +report = study.validate_conformance() +print(report) +``` + +When every check passes the report shows `PASS` for each domain. The `all_passed()` method +gives you a single boolean for use in scripts and pipelines: + +```{python} +print("Passed:", report.all_passed()) +``` + +### What the Cross-Dataset Checks Cover + +The native engine adds the following checks automatically when the relevant datasets are +present: + +| Check | Condition | +|---|---| +| `USUBJID` referential integrity | Every domain with `USUBJID` is checked against `DM.USUBJID` | +| SUPP-- `RDOMAIN` present | The referenced parent domain must exist in the package | +| SUPP-- `USUBJID` in DM | Supplemental rows must link to a known subject | +| SUPP-- `IDVAR/IDVARVAL` resolves | The sequence link must match a record in the parent domain | +| RELREC `RDOMAIN` present | Every related-records row references a present domain | +| ADSL traces to DM | `ADSL.USUBJID` must be a subset of `DM.USUBJID` | +| ADaM traces to ADSL | Every other ADaM dataset's subjects must appear in ADSL | + +Each check is a `specially()` step in the per-dataset `Validate` plan, so the standard +Pointblank failure drill-down workflow applies: you can see exactly which rows failed and why. + +### Catching Referential Integrity Problems + +Adding a subject who is not in DM to an AE domain is one of the most common conformance +errors. The native engine catches it immediately: + +```{python} +# Subject STUDY01-999 appears in AE but not in DM +ae_with_orphan = pl.DataFrame({ + "STUDYID": ["STUDY01"] * 3, + "DOMAIN": ["AE"] * 3, + "USUBJID": ["STUDY01-001", "STUDY01-002", "STUDY01-999"], # 999 is not in DM + "AESEQ": [1, 1, 1], + "AETERM": ["Headache", "Nausea", "Dizziness"], +}) + +study_with_orphan = pb.SubmissionPackage( + datasets={"DM": dm, "AE": ae_with_orphan}, + study_id="STUDY01", +) + +report = study_with_orphan.validate_conformance() +print("Passed:", report.all_passed()) + +# Drill into the specific issue +for issue in report.issues(): + print(f" [{issue['dataset']}] step {issue['step']}: " + f"{issue['n_failed']} failing row(s)") +``` + +You can reach into the per-dataset `Validate` object directly to inspect individual steps: + +```{python} +ae_validation = report["AE"] +for step in ae_validation.validation_info: + if step.n_failed: + print(f" Check: {step.brief}") + print(f" Failed rows: {step.n_failed}") +``` + +### SUPP-- Linkage Checks + +Supplemental Qualifiers datasets (`SUPP--`) add non-standard variables to a parent domain. The +native engine checks three things: the `RDOMAIN` column must reference a domain that exists in +the package, the `USUBJID` must appear in DM, and the `IDVAR/IDVARVAL` combination must resolve +to a record in the parent domain. + +```{python} +suppae = pl.DataFrame({ + "STUDYID": ["STUDY01"], + "RDOMAIN": ["AE"], # must match a present domain + "USUBJID": ["STUDY01-001"], # must appear in DM + "IDVAR": ["AESEQ"], # column name in the parent AE dataset + "IDVARVAL": ["1"], # value of that column; resolves to row 1 of AE + "QNAM": ["AEACN"], + "QLABEL": ["Action Taken"], + "QVAL": ["DOSE REDUCED"], +}) + +study_with_supp = pb.SubmissionPackage( + datasets={"DM": dm, "AE": ae, "SUPPAE": suppae}, + study_id="STUDY01", +) + +report = study_with_supp.validate_conformance() +print("Passed:", report.all_passed()) +``` + +### ADaM Traceability + +For ADaM packages the native engine checks that every subject in ADSL traces to a record in DM, +and that every other ADaM dataset's subjects trace to ADSL. These checks implement the +"derivable from SDTM" principle at the subject level: + +```{python} +adsl = pl.DataFrame({ + "STUDYID": ["STUDY01"] * 4, + "USUBJID": ["STUDY01-001", "STUDY01-002", "STUDY01-003", "STUDY01-004"], + "SUBJID": ["001", "002", "003", "004"], + "TRT01P": ["Arm A", "Arm B", "Arm A", "Arm B"], + "TRT01A": ["Arm A", "Arm B", "Arm A", "Arm B"], + "AGE": [45, 62, 38, 55], + "SEX": ["M", "F", "M", "F"], + "RACE": ["WHITE", "ASIAN", "WHITE", "BLACK OR AFRICAN AMERICAN"], + "COUNTRY": ["USA"] * 4, + "SAFFL": ["Y", "Y", "Y", "Y"], + "ITTFL": ["Y", "Y", "Y", "Y"], +}) + +adae = pl.DataFrame({ + "STUDYID": ["STUDY01", "STUDY01"], + "USUBJID": ["STUDY01-001", "STUDY01-002"], + "AESEQ": [1, 1], + "AETERM": ["Headache", "Nausea"], + "TRTEMFL": ["Y", "Y"], +}) + +adam_study = pb.SubmissionPackage( + datasets={"DM": dm, "ADSL": adsl, "ADAE": adae}, + standard="adamig", + standard_version="1.1", + study_id="STUDY01", +) + +report = adam_study.validate_conformance() +print("Passed:", report.all_passed()) +``` + +### Disabling Cross-Dataset Checks + +Pass `cross_dataset=False` to run only the per-dataset structural checks without any graph +traversal. This is useful when you want to understand the structural baseline before adding the +relational rules: + +```{python} +report = study_with_orphan.validate_conformance(cross_dataset=False) +# The orphan subject is not flagged because referential checks are disabled +print("Passed (structural only):", report.all_passed()) +``` + +## Ingesting a Folder of Datasets + +If your study datasets live on disk as XPT files, use `from_folder()` to read them all at once. +Pointblank reads every `.xpt` file in the folder and derives the domain name from the file stem. +If a `define.xml` is present it is picked up automatically: + +```{python} +#| eval: false +study = pb.SubmissionPackage.from_folder( + "path/to/sdtm/", + standard="sdtmig", + standard_version="3.4", + study_id="STUDY01", +) + +report = study.validate_conformance() +``` + +Dataset-JSON (`.json`) files in the Dataset-JSON 1.1 format are also supported alongside XPT. +Pointblank reads both formats from the same folder. + +## Installing the CDISC CORE Engine + +The CDISC CORE engine runs the full authoritative conformance rule set. It is developed and +maintained by CDISC as open-source software. Pointblank invokes it as an external subprocess; +the engine is intentionally not a Python dependency so that version constraints do not conflict. + +### Option 1: Standalone Executable + +Download the pre-built standalone executable from the +[CDISC CORE releases page](https://github.com/cdisc-org/cdisc-rules-engine/releases). +The executable bundles the Python runtime and rules cache and requires no other installation. +Place it somewhere on your `PATH` under the name `core`: + +```bash +# On macOS/Linux, after downloading and making it executable: +chmod +x core +sudo mv core /usr/local/bin/ + +# Verify: +core --version +``` + +### Option 2: Docker + +CDISC publishes an official Docker image that includes the engine and its full rules cache: + +```bash +docker pull cdisc/cdisc-rules-engine:latest + +# Run a validation (bind-mount your data directory): +docker run --rm \ + -v /path/to/study/data:/data \ + cdisc/cdisc-rules-engine:latest \ + validate -s sdtmig -v 3-4 -d /data -of JSON -o /data/report +``` + +### Option 3: Repo Checkout + +For development or to inspect CORE's source: + +```bash +git clone https://github.com/cdisc-org/cdisc-rules-engine.git +cd cdisc-rules-engine +pip install -r requirements.txt +python core.py --version +``` + +When using a repo checkout, CORE resolves its bundled rules cache relative to its current +working directory. You must pass the repo root as `core_cwd` so Pointblank sets the subprocess +working directory correctly. + +### Telling Pointblank Where to Find CORE + +Pointblank discovers CORE through three mechanisms, tried in order: + +1. An explicit `core=` argument to `validate_cdisc_submission()` or + `validate_conformance(engine="core")`. +2. The `POINTBLANK_CDISC_CORE` environment variable. Set this to the path of the executable or + a full command prefix such as `"python /path/to/core.py"`. +3. A `core` or `cdisc-rules-engine` executable on `PATH`. + +## Delegating to the CDISC CORE Engine + +### The One-Call Entry Point + +`validate_cdisc_submission()` is the simplest way to run a CORE validation. It accepts an +in-memory dictionary of DataFrames, a folder path, or an existing `SubmissionPackage`: + +```{python} +#| eval: false +import polars as pl +import pointblank as pb + +dm = pl.DataFrame({...}) # your DM dataset + +report = pb.validate_cdisc_submission( + {"DM": dm}, + standard="sdtmig", + version="3.4", +) + +print(report) +``` + +For a folder of XPT files: + +```{python} +#| eval: false +report = pb.validate_cdisc_submission( + "path/to/sdtm/", + standard="sdtmig", + version="3.4", + agency="FDA", +) +``` + +### Using SubmissionPackage.validate_conformance() + +The same result with more control: build the package first, then choose the engine: + +```{python} +#| eval: false +study = pb.SubmissionPackage( + datasets={"DM": dm, "AE": ae}, + standard="sdtmig", + standard_version="3.4", + study_id="STUDY01", +) + +# Run the full CDISC CORE rule set +core_report = study.validate_conformance( + engine="core", + agency="FDA", + controlled_terminology="sdtmct-2024-03-29", +) +``` + +### How In-Memory Datasets Reach CORE + +When you pass DataFrames directly, Pointblank materializes them to SAS Transport (XPT) files in +a temporary working directory, runs CORE against that directory, and then cleans up. For packages +read with `from_folder()`, Pointblank skips the materialization step and passes the on-disk +folder directly to CORE, which is faster and avoids any XPT conversion overhead. + +You can pin the working directory to avoid the cleanup and inspect the materialized files: + +```{python} +#| eval: false +report = study.validate_conformance( + engine="core", + workdir="/tmp/my_core_run", # not cleaned up; inspect dm.xpt, ae.xpt, core_report.json +) +``` + +### Repo-Checkout Invocation + +When using a CORE repo checkout, pass the command prefix and repo root: + +```{python} +#| eval: false +report = pb.validate_cdisc_submission( + {"DM": dm}, + standard="sdtmig", + version="3.4", + core=["python", "/path/to/cdisc-rules-engine/core.py"], + core_cwd="/path/to/cdisc-rules-engine", + cache="/path/to/cdisc-rules-engine/resources/cache", +) +``` + +Or configure once with environment variables and then call without any extra arguments: + +```bash +export POINTBLANK_CDISC_CORE="python /path/to/core.py" +export POINTBLANK_CDISC_CORE_CWD="/path/to/cdisc-rules-engine" +``` + +```{python} +#| eval: false +# Pointblank reads POINTBLANK_CDISC_CORE from the environment automatically +report = pb.validate_cdisc_submission({"DM": dm}, standard="sdtmig", version="3.4") +``` + +## Working with a CORE ConformanceReport + +The examples that follow use a captured real report so that the code runs without requiring CORE +to be installed in the docs environment. The structure is identical to what you get from a live +CORE run. + +```{python} +import json +from pathlib import Path +from pointblank.metadata import parse_core_report, ConformanceReport + +# Load a captured CORE 0.16.0 report (SDTMIG 3.4, 430 rules) +_fixtures = Path(pb.__file__).parent.parent / "tests" / "metadata_fixtures" / "cdisc_core" +raw = json.loads((_fixtures / "core_report_full.json").read_text()) + +report = ConformanceReport.from_core_report(raw, agency="FDA") +print(report) +``` + +### Checking the Overall Result + +```{python} +# Single boolean for use in scripts and pipelines +print("All passed:", report.all_passed()) + +# Distinguish report types +print("Is CORE report:", report.is_core) +``` + +### The Summary Dictionary + +`summary()` returns a high-level overview of the run: the standard and version CORE validated +against, the engine version that produced the report, total rule count, per-status counts, total +issue count, and the pass/fail verdict: + +```{python} +s = report.summary() + +print(f"Standard: {s['standard']} {s['version']}") +print(f"Engine: {s['engine_version']}") +print(f"Rules evaluated: {s['n_rules']}") +print(f"Total issues: {s['n_issues']}") +print() + +print("Rule status breakdown:") +for status, count in sorted(s["status_counts"].items()): + print(f" {status:20s}: {count}") +``` + +### Inspecting Issues + +`issues()` returns one record per (dataset, rule) pair that reported at least one issue. Each +record includes the dataset name, rule ID, human-readable message, issue count, and the rule's +run status: + +```{python} +issues = report.issues() +print(f"Issue entries: {len(issues)}") + +# Show the first few issues +for issue in issues[:3]: + print() + print(f" Dataset: {issue['dataset']}") + print(f" Rule: {issue['rule_id']}") + print(f" Message: {issue['message']}") + print(f" Issues: {issue['issues']}") + print(f" Status: {issue['status']}") +``` + +You can filter by run status to focus on a specific category. The two failing statuses are +`"ISSUE REPORTED"` (the rule ran and found a problem) and `"EXECUTION ERROR"` (the rule could +not run): + +```{python} +from pointblank.metadata._cdisc_core import STATUS_ISSUE, STATUS_ERROR + +# Rules that actually found conformance problems +reported_issues = report.issues(status=STATUS_ISSUE) +print(f"Rules with issues: {len(reported_issues)}") + +# Rules that failed to execute (data was missing something they expected) +exec_errors = report.issues(status=STATUS_ERROR) +print(f"Execution errors: {len(exec_errors)}") +for e in exec_errors: + print(f" {e['rule_id']}: {e['message']}") +``` + +### Row-Level Findings + +`findings()` goes deeper: it returns the row-level detail from CORE's `Issue_Details` section. +Each finding points to a specific dataset, row number, USUBJID, and the variable(s) that +triggered the rule: + +```{python} +from pointblank.metadata import CoreFinding + +findings = report.findings() +print(f"Row-level findings: {len(findings)}") + +# Examine one finding in detail +f = findings[0] +print() +print(f"Rule: {f.rule_id}") +print(f"Dataset: {f.dataset}") +print(f"Message: {f.message}") +print(f"Row: {f.row}") +print(f"USUBJID: {f.usubjid}") +print(f"Variables: {f.variables}") +print(f"Values: {f.values}") +``` + +### Per-Rule Run Results + +`rules()` returns the complete `Rules_Report`: one `CoreRuleResult` per rule, with its run +status, message, and the corresponding CDISC and FDA rule identifiers: + +```{python} +from pointblank.metadata import CoreRuleResult +from pointblank.metadata._cdisc_core import STATUS_SUCCESS, STATUS_SKIPPED + +all_rules = report.rules() +print(f"Total rules: {len(all_rules)}") + +# Filter to rules that passed +successful = report.rules(status=STATUS_SUCCESS) +print(f"Successful: {len(successful)}") + +# Filter to rules that were skipped (not applicable to this dataset) +skipped = report.rules(status=STATUS_SKIPPED) +print(f"Skipped: {len(skipped)}") + +# Show one successful rule's metadata +r = successful[0] +print() +print(f"Rule ID: {r.rule_id}") +print(f"Status: {r.status}") +print(f"Message: {r.message}") +print(f"CDISC rule ID: {r.cdisc_rule_id}") +print(f"FDA rule ID: {r.fda_rule_id}") +``` + +Rules that are skipped are not failures. CORE skips rules when the dataset or variable they +check is absent from the submission. A rule like "AE requires AESTDTC" is skipped if there is +no AE domain in the package; that is expected behavior, not a problem. + +### Identifying Failing Rules + +The `is_failing` property on a `CoreRuleResult` is `True` when its status is either +`"ISSUE REPORTED"` or `"EXECUTION ERROR"`: + +```{python} +failing = [r for r in all_rules if r.is_failing] +print(f"Failing rules: {len(failing)}") +for r in failing: + print(f" {r.rule_id:15s} [{r.status}] {(r.message or '')[:60]}") +``` + +## Exporting Reports + +### JSON Export + +`to_json()` saves the report as a JSON file. For CORE reports the output mirrors the original +CORE report structure (`Conformance_Details`, `Dataset_Details`, `Issue_Summary`, +`Issue_Details`, `Rules_Report`), so the file is parseable by `parse_core_report()` and by +any other tool that understands CORE's JSON output: + +```{python} +import tempfile +from pathlib import Path + +with tempfile.TemporaryDirectory() as tmp: + dest = report.to_json(Path(tmp) / "conformance_report.json") + print(f"Written to: {dest.name}") + print(f"File size: {dest.stat().st_size:,} bytes") + + # Verify it round-trips cleanly + reloaded = json.loads(dest.read_text()) + reparsed = parse_core_report(reloaded) + print(f"Rules after round-trip: {len(reparsed.rules)}") + print(f"Issues after round-trip: {reparsed.n_total_issues}") +``` + +For native reports the JSON file contains `summary` and `issues` keys, with the same content +as `report.summary()` and `report.issues()`. + +### Excel Export + +`to_excel()` writes the report as an Excel workbook. Requires `openpyxl`: + +```{python} +#| eval: false +dest = report.to_excel("conformance_report.xlsx") +``` + +For CORE reports the workbook contains four sheets: + +| Sheet | Contents | +|---|---| +| `Issue_Summary` | One row per (dataset, rule) pair that reported issues | +| `Issue_Details` | Row-level findings with USUBJID, row number, and variable values | +| `Rules_Report` | All rules with their run status, CDISC rule ID, and FDA rule ID | +| `Conformance_Details` | Run provenance: standard, version, engine version, timestamps | + +For native reports the workbook contains `Issues` and `Summary` sheets. + +## Setting the Agency + +Pass `agency="FDA"` or `agency="PMDA"` to record which regulatory context the validation was +run for. The agency is stored on the `ConformanceReport` and appears in its text and HTML +representations. Agency-specific business rule filtering is a later phase; this currently +affects labeling only: + +```{python} +#| eval: false +fda_report = pb.validate_cdisc_submission( + {"DM": dm}, + standard="sdtmig", + version="3.4", + agency="FDA", +) +print(fda_report.agency) # "FDA" +``` + +## Using Both Engines in the Same Workflow + +The two engines complement each other. The native engine gives immediate feedback during +development with no external dependencies. The CORE engine provides the authoritative check +before submission: + +```{python} +#| eval: false +# Step 1: rapid iteration with the native engine +study = pb.SubmissionPackage( + datasets={"DM": dm, "AE": ae}, + study_id="STUDY01", +) + +native_report = study.validate_conformance() +if not native_report.all_passed(): + print("Fix cross-dataset issues before running CORE:") + for issue in native_report.issues(): + print(f" [{issue['dataset']}] {issue['assertion']}: {issue['n_failed']} rows") + raise SystemExit(1) + +# Step 2: final gate with CORE +core_report = study.validate_conformance( + engine="core", + agency="FDA", + controlled_terminology="sdtmct-2024-03-29", +) + +if not core_report.all_passed(): + failing = [r for r in core_report.rules() if r.is_failing] + print(f"CORE found {len(failing)} failing rules.") + core_report.to_json("core_report.json") + core_report.to_excel("core_report.xlsx") + raise SystemExit(1) + +print("Submission passed all conformance checks.") +``` + +## Running the Integration Tests + +Pointblank ships integration tests that run the full CORE pipeline against real data. They are +marked with `@pytest.mark.cdisc_core` and are skipped automatically in environments where CORE +is not discoverable: + +```bash +# Run only when CORE is available on PATH: +pytest -m cdisc_core + +# With a repo-checkout CORE, set env vars first: +export POINTBLANK_CDISC_CORE="python /path/to/core.py" +export POINTBLANK_CDISC_CORE_CWD="/path/to/cdisc-rules-engine" +export POINTBLANK_CDISC_CORE_CACHE="/path/to/cdisc-rules-engine/resources/cache" +pytest -m cdisc_core +``` + +The tests live in `tests/test_cdisc_core_integration.py` and exercise the full pipeline +including folder passthrough, all report accessors, and both export formats. From 03ec5e977ab2e061e18f6023b5cc0ffceb84137a Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:13:51 -0400 Subject: [PATCH 29/93] Add SDTMIG 3.4 rules and SDTM CT package --- .../conformance/ct/sdtm-ct-2024-09-27.json | 254 ++++++ .../data/conformance/rules/sdtmig-3-4.json | 724 ++++++++++++++++++ 2 files changed, 978 insertions(+) create mode 100644 pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json create mode 100644 pointblank/data/conformance/rules/sdtmig-3-4.json diff --git a/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json b/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json new file mode 100644 index 000000000..a3eb883ae --- /dev/null +++ b/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json @@ -0,0 +1,254 @@ +{ + "package": "sdtm-ct-2024-09-27", + "source": "NCI EVS CDISC Controlled Terminology 2024-09-27", + "codelists": { + "SEX": [ + "M", + "F", + "U", + "UNDIFFERENTIATED" + ], + "NY": [ + "Y", + "N" + ], + "ETHNIC": [ + "HISPANIC OR LATINO", + "NOT HISPANIC OR LATINO", + "NOT REPORTED", + "UNKNOWN" + ], + "AEOUT": [ + "FATAL", + "NOT RECOVERED/NOT RESOLVED", + "RECOVERED/RESOLVED", + "RECOVERED/RESOLVED WITH SEQUELAE", + "RECOVERING/RESOLVING", + "UNKNOWN" + ], + "AESEV": [ + "MILD", + "MODERATE", + "SEVERE" + ], + "AEREL": [ + "NOT RELATED", + "UNLIKELY RELATED", + "POSSIBLY RELATED", + "PROBABLY RELATED", + "RELATED" + ], + "AETOXGR": [ + "1", + "2", + "3", + "4", + "5" + ], + "RACE": [ + "AMERICAN INDIAN OR ALASKA NATIVE", + "ASIAN", + "BLACK OR AFRICAN AMERICAN", + "NATIVE HAWAIIAN OR OTHER PACIFIC ISLANDER", + "NOT REPORTED", + "UNKNOWN", + "WHITE", + "MULTIPLE" + ], + "COUNTRY": [ + "AFG", + "ALB", + "DZA", + "AND", + "AGO", + "ATG", + "ARG", + "ARM", + "AUS", + "AUT", + "AZE", + "BHS", + "BHR", + "BGD", + "BRB", + "BLR", + "BEL", + "BLZ", + "BEN", + "BTN", + "BOL", + "BIH", + "BWA", + "BRA", + "BRN", + "BGR", + "BFA", + "BDI", + "CPV", + "KHM", + "CMR", + "CAN", + "CAF", + "TCD", + "CHL", + "CHN", + "COL", + "COM", + "COD", + "COG", + "CRI", + "CIV", + "HRV", + "CUB", + "CYP", + "CZE", + "DNK", + "DJI", + "DOM", + "ECU", + "EGY", + "SLV", + "GNQ", + "ERI", + "EST", + "SWZ", + "ETH", + "FJI", + "FIN", + "FRA", + "GAB", + "GMB", + "GEO", + "DEU", + "GHA", + "GRC", + "GRD", + "GTM", + "GIN", + "GNB", + "GUY", + "HTI", + "HND", + "HUN", + "ISL", + "IND", + "IDN", + "IRN", + "IRQ", + "IRL", + "ISR", + "ITA", + "JAM", + "JPN", + "JOR", + "KAZ", + "KEN", + "KIR", + "PRK", + "KOR", + "KWT", + "KGZ", + "LAO", + "LVA", + "LBN", + "LSO", + "LBR", + "LBY", + "LIE", + "LTU", + "LUX", + "MDG", + "MWI", + "MYS", + "MDV", + "MLI", + "MLT", + "MHL", + "MRT", + "MUS", + "MEX", + "FSM", + "MDA", + "MCO", + "MNG", + "MNE", + "MAR", + "MOZ", + "MMR", + "NAM", + "NRU", + "NPL", + "NLD", + "NZL", + "NIC", + "NER", + "NGA", + "MKD", + "NOR", + "OMN", + "PAK", + "PLW", + "PAN", + "PNG", + "PRY", + "PER", + "PHL", + "POL", + "PRT", + "QAT", + "ROU", + "RUS", + "RWA", + "KNA", + "LCA", + "VCT", + "WSM", + "SMR", + "STP", + "SAU", + "SEN", + "SRB", + "SYC", + "SLE", + "SGP", + "SVK", + "SVN", + "SLB", + "SOM", + "ZAF", + "SSD", + "ESP", + "LKA", + "SDN", + "SUR", + "SWE", + "CHE", + "SYR", + "TWN", + "TJK", + "TZA", + "THA", + "TLS", + "TGO", + "TON", + "TTO", + "TUN", + "TUR", + "TKM", + "TUV", + "UGA", + "UKR", + "ARE", + "GBR", + "USA", + "URY", + "UZB", + "VUT", + "VEN", + "VNM", + "YEM", + "ZMB", + "ZWE" + ] + } +} \ No newline at end of file diff --git a/pointblank/data/conformance/rules/sdtmig-3-4.json b/pointblank/data/conformance/rules/sdtmig-3-4.json new file mode 100644 index 000000000..61300bc15 --- /dev/null +++ b/pointblank/data/conformance/rules/sdtmig-3-4.json @@ -0,0 +1,724 @@ +{ + "standard": "sdtmig", + "version": "3.4", + "generated": "2025-07-13T00:00:00Z", + "source": "CDISC SDTM Implementation Guide 3.4, hand-curated from public specification", + "checksum": "sdtmig-3-4-v1", + "rules": [ + { + "core_id": "SDTM-001", + "rule_type": "DOMAIN_PRESENCE_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DM (Demographics) domain is required in every SDTM submission.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": [], + "domains": [], + "datasets": [], + "operations": [], + "conditions": {}, + "actions": { + "id": "domain_presence", + "params": { + "required_domains": ["DM"], + "prohibited_domains": [], + "message": "DM domain must be present in every SDTM submission." + } + } + }, + { + "core_id": "SDTM-002", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "STUDYID must not be null in any domain.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["All"], + "domains": [], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + {"name": "STUDYID", "operator": "is_null", "value": null} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "STUDYID must not be null."} + } + }, + { + "core_id": "SDTM-003", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN must not be null in any SDTM dataset.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["All"], + "domains": [], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + {"name": "DOMAIN", "operator": "is_null", "value": null} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "DOMAIN must not be null."} + } + }, + { + "core_id": "SDTM-004", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "USUBJID must not be null in any SDTM dataset.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["All"], + "domains": [], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + {"name": "USUBJID", "operator": "is_null", "value": null} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "USUBJID must not be null."} + } + }, + { + "core_id": "SDTM-005", + "rule_type": "DATASET_CONTENTS_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "STUDYID must be consistent (same value) across all records in a dataset.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["All"], + "domains": [], + "datasets": [], + "operations": [ + {"operator": "consistency_check", "params": {"column": "STUDYID"}} + ], + "conditions": { + "all": [ + {"name": "_pb_STUDYID_consistent", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_dataset_error", + "params": {"message": "STUDYID must be the same value across all records."} + } + }, + { + "core_id": "SDTM-006", + "rule_type": "DATASET_CONTENTS_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN must be consistent (same value) across all records in a dataset.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["All"], + "domains": [], + "datasets": [], + "operations": [ + {"operator": "consistency_check", "params": {"column": "DOMAIN"}} + ], + "conditions": { + "all": [ + {"name": "_pb_DOMAIN_consistent", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_dataset_error", + "params": {"message": "DOMAIN must be the same value across all records."} + } + }, + { + "core_id": "SDTM-007", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "SEX in DM must use values from the CDISC controlled terminology codelist SEX.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Special-Purpose"], + "domains": ["DM"], + "datasets": [], + "operations": [ + {"operator": "codelist_check", "params": {"column": "SEX", "codelist": "SEX"}} + ], + "conditions": { + "all": [ + {"name": "SEX", "operator": "is_not_null", "value": null}, + {"name": "_pb_SEX_valid", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "SEX value is not in the SEX codelist."} + } + }, + { + "core_id": "SDTM-008", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RACE in DM must use values from the CDISC controlled terminology codelist RACE.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Special-Purpose"], + "domains": ["DM"], + "datasets": [], + "operations": [ + {"operator": "codelist_check", "params": {"column": "RACE", "codelist": "RACE"}} + ], + "conditions": { + "all": [ + {"name": "RACE", "operator": "is_not_null", "value": null}, + {"name": "_pb_RACE_valid", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "RACE value is not in the RACE codelist."} + } + }, + { + "core_id": "SDTM-009", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "COUNTRY in DM must use ISO 3166 alpha-3 country codes (CDISC COUNTRY codelist).", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Special-Purpose"], + "domains": ["DM"], + "datasets": [], + "operations": [ + {"operator": "codelist_check", "params": {"column": "COUNTRY", "codelist": "COUNTRY"}} + ], + "conditions": { + "all": [ + {"name": "COUNTRY", "operator": "is_not_null", "value": null}, + {"name": "_pb_COUNTRY_valid", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "COUNTRY value is not in the COUNTRY codelist (ISO 3166 alpha-3)."} + } + }, + { + "core_id": "SDTM-010", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DMDTC in DM must be in ISO 8601 extended datetime format.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Special-Purpose"], + "domains": ["DM"], + "datasets": [], + "operations": [ + {"operator": "iso8601_check", "params": {"column": "DMDTC"}} + ], + "conditions": { + "all": [ + {"name": "DMDTC", "operator": "is_not_null", "value": null}, + {"name": "_pb_DMDTC_iso8601", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "DMDTC does not conform to ISO 8601 extended datetime format."} + } + }, + { + "core_id": "SDTM-011", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RFSTDTC in DM must be in ISO 8601 extended datetime format when present.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Special-Purpose"], + "domains": ["DM"], + "datasets": [], + "operations": [ + {"operator": "iso8601_check", "params": {"column": "RFSTDTC"}} + ], + "conditions": { + "all": [ + {"name": "RFSTDTC", "operator": "is_not_null", "value": null}, + {"name": "_pb_RFSTDTC_iso8601", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "RFSTDTC does not conform to ISO 8601 extended datetime format."} + } + }, + { + "core_id": "SDTM-012", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RFENDTC in DM must be in ISO 8601 extended datetime format when present.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Special-Purpose"], + "domains": ["DM"], + "datasets": [], + "operations": [ + {"operator": "iso8601_check", "params": {"column": "RFENDTC"}} + ], + "conditions": { + "all": [ + {"name": "RFENDTC", "operator": "is_not_null", "value": null}, + {"name": "_pb_RFENDTC_iso8601", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "RFENDTC does not conform to ISO 8601 extended datetime format."} + } + }, + { + "core_id": "SDTM-013", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DTHDTC in DM must be in ISO 8601 extended datetime format when present.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Special-Purpose"], + "domains": ["DM"], + "datasets": [], + "operations": [ + {"operator": "iso8601_check", "params": {"column": "DTHDTC"}} + ], + "conditions": { + "all": [ + {"name": "DTHDTC", "operator": "is_not_null", "value": null}, + {"name": "_pb_DTHDTC_iso8601", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "DTHDTC does not conform to ISO 8601 extended datetime format."} + } + }, + { + "core_id": "SDTM-014", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DTHFL in DM must use values from the NY codelist (Y or null).", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Special-Purpose"], + "domains": ["DM"], + "datasets": [], + "operations": [ + {"operator": "codelist_check", "params": {"column": "DTHFL", "codelist": "NY"}} + ], + "conditions": { + "all": [ + {"name": "DTHFL", "operator": "is_not_null", "value": null}, + {"name": "_pb_DTHFL_valid", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "DTHFL value must be 'Y' or null (NY codelist)."} + } + }, + { + "core_id": "SDTM-015", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "SUBJID must not be null in DM.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Special-Purpose"], + "domains": ["DM"], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + {"name": "SUBJID", "operator": "is_null", "value": null} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "SUBJID must not be null in DM."} + } + }, + { + "core_id": "SDTM-016", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AETERM must not be null in AE.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Events"], + "domains": ["AE"], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + {"name": "AETERM", "operator": "is_null", "value": null} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "AETERM (Reported Term for the Adverse Event) must not be null in AE."} + } + }, + { + "core_id": "SDTM-017", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AEDECOD must not be null in AE.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Events"], + "domains": ["AE"], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + {"name": "AEDECOD", "operator": "is_null", "value": null} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "AEDECOD (Dictionary-Derived Term) must not be null in AE."} + } + }, + { + "core_id": "SDTM-018", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESTDTC in AE must be in ISO 8601 extended datetime format when present.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Events"], + "domains": ["AE"], + "datasets": [], + "operations": [ + {"operator": "iso8601_check", "params": {"column": "AESTDTC"}} + ], + "conditions": { + "all": [ + {"name": "AESTDTC", "operator": "is_not_null", "value": null}, + {"name": "_pb_AESTDTC_iso8601", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "AESTDTC does not conform to ISO 8601 extended datetime format."} + } + }, + { + "core_id": "SDTM-019", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AEENDTC in AE must be in ISO 8601 extended datetime format when present.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Events"], + "domains": ["AE"], + "datasets": [], + "operations": [ + {"operator": "iso8601_check", "params": {"column": "AEENDTC"}} + ], + "conditions": { + "all": [ + {"name": "AEENDTC", "operator": "is_not_null", "value": null}, + {"name": "_pb_AEENDTC_iso8601", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "AEENDTC does not conform to ISO 8601 extended datetime format."} + } + }, + { + "core_id": "SDTM-020", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESER in AE must use values from the NY codelist when present.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Events"], + "domains": ["AE"], + "datasets": [], + "operations": [ + {"operator": "codelist_check", "params": {"column": "AESER", "codelist": "NY"}} + ], + "conditions": { + "all": [ + {"name": "AESER", "operator": "is_not_null", "value": null}, + {"name": "_pb_AESER_valid", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "AESER value must be in the NY codelist (Y/N)."} + } + }, + { + "core_id": "SDTM-021", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AEOUT in AE must use values from the AEOUT codelist when present.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Events"], + "domains": ["AE"], + "datasets": [], + "operations": [ + {"operator": "codelist_check", "params": {"column": "AEOUT", "codelist": "AEOUT"}} + ], + "conditions": { + "all": [ + {"name": "AEOUT", "operator": "is_not_null", "value": null}, + {"name": "_pb_AEOUT_valid", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "AEOUT value is not in the AEOUT codelist."} + } + }, + { + "core_id": "SDTM-022", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBDTC in LB must be in ISO 8601 extended datetime format when present.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Findings"], + "domains": ["LB"], + "datasets": [], + "operations": [ + {"operator": "iso8601_check", "params": {"column": "LBDTC"}} + ], + "conditions": { + "all": [ + {"name": "LBDTC", "operator": "is_not_null", "value": null}, + {"name": "_pb_LBDTC_iso8601", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "LBDTC does not conform to ISO 8601 extended datetime format."} + } + }, + { + "core_id": "SDTM-023", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBTEST must not be null in LB.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Findings"], + "domains": ["LB"], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + {"name": "LBTEST", "operator": "is_null", "value": null} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "LBTEST (Lab Test Name) must not be null in LB."} + } + }, + { + "core_id": "SDTM-024", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBTESTCD must not be null in LB.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Findings"], + "domains": ["LB"], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + {"name": "LBTESTCD", "operator": "is_null", "value": null} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "LBTESTCD (Lab Test Short Name) must not be null in LB."} + } + }, + { + "core_id": "SDTM-025", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSTEST must not be null in VS.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Findings"], + "domains": ["VS"], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + {"name": "VSTEST", "operator": "is_null", "value": null} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "VSTEST (Vital Signs Test Name) must not be null in VS."} + } + }, + { + "core_id": "SDTM-026", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSDTC in VS must be in ISO 8601 extended datetime format when present.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Findings"], + "domains": ["VS"], + "datasets": [], + "operations": [ + {"operator": "iso8601_check", "params": {"column": "VSDTC"}} + ], + "conditions": { + "all": [ + {"name": "VSDTC", "operator": "is_not_null", "value": null}, + {"name": "_pb_VSDTC_iso8601", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "VSDTC does not conform to ISO 8601 extended datetime format."} + } + }, + { + "core_id": "SDTM-027", + "rule_type": "DATASET_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Warning", + "description": "USUBJID must be present in every SDTM domain.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["All"], + "domains": [], + "datasets": [], + "operations": [ + {"operator": "column_presence", "params": {"column": "USUBJID"}} + ], + "conditions": { + "all": [ + {"name": "_pb_USUBJID_present", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_dataset_error", + "params": {"message": "USUBJID column is required in all SDTM domains."} + } + }, + { + "core_id": "SDTM-028", + "rule_type": "DATASET_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "STUDYID must be present in every SDTM domain.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["All"], + "domains": [], + "datasets": [], + "operations": [ + {"operator": "column_presence", "params": {"column": "STUDYID"}} + ], + "conditions": { + "all": [ + {"name": "_pb_STUDYID_present", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_dataset_error", + "params": {"message": "STUDYID column is required in all SDTM domains."} + } + }, + { + "core_id": "SDTM-029", + "rule_type": "DATASET_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN must be present in every SDTM domain.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["All"], + "domains": [], + "datasets": [], + "operations": [ + {"operator": "column_presence", "params": {"column": "DOMAIN"}} + ], + "conditions": { + "all": [ + {"name": "_pb_DOMAIN_present", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_dataset_error", + "params": {"message": "DOMAIN column is required in all SDTM domains."} + } + }, + { + "core_id": "SDTM-030", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "ETHNIC in DM must use values from the ETHNIC codelist when present.", + "authority": "CDISC", + "standards": ["sdtmig"], + "classes": ["Special-Purpose"], + "domains": ["DM"], + "datasets": [], + "operations": [ + {"operator": "codelist_check", "params": {"column": "ETHNIC", "codelist": "ETHNIC"}} + ], + "conditions": { + "all": [ + {"name": "ETHNIC", "operator": "is_not_null", "value": null}, + {"name": "_pb_ETHNIC_valid", "operator": "equal_to", "value": false} + ] + }, + "actions": { + "id": "generate_record_error", + "params": {"message": "ETHNIC value is not in the ETHNIC codelist."} + } + } + ] +} From 26884fbb699aa938799f170c1282216a313c8393 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:15:09 -0400 Subject: [PATCH 30/93] Add bundled CDISC CT loader utility --- pointblank/metadata/_conformance/ct.py | 95 ++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 pointblank/metadata/_conformance/ct.py diff --git a/pointblank/metadata/_conformance/ct.py b/pointblank/metadata/_conformance/ct.py new file mode 100644 index 000000000..04b6143cf --- /dev/null +++ b/pointblank/metadata/_conformance/ct.py @@ -0,0 +1,95 @@ +"""Bundled CDISC Controlled Terminology loader.""" + +from __future__ import annotations + +import json +from pathlib import Path + +_CT_DIR = Path(__file__).parent.parent.parent / "data" / "conformance" / "ct" + + +class ControlledTerminology: + """Query bundled CDISC CT packages for codelist membership. + + Parameters + ---------- + codelists + Mapping of codelist name (e.g. `"SEX"`) to the set of permitted submission values. + packages + The CT package identifiers that were loaded (for provenance). + """ + + def __init__(self, codelists: dict[str, set[str]], packages: list[str]) -> None: + self._codelists = codelists + self.packages = packages + + @classmethod + def load(cls, packages: list[str]) -> ControlledTerminology: + """Load one or more bundled CT packages. + + Parameters + ---------- + packages + CT package slugs (e.g. `["sdtm-ct-2024-09-27"]`). Files must exist under + `pointblank/data/conformance/ct/`. + + Returns + ------- + ControlledTerminology + A merged view across all requested packages; later packages override earlier ones when + the same codelist appears in both. + """ + codelists: dict[str, set[str]] = {} + loaded: list[str] = [] + for pkg in packages: + path = _CT_DIR / f"{pkg}.json" + if not path.exists(): + raise FileNotFoundError( + f"No bundled CT package '{pkg}'. " + f"Available: {cls.available()}. " + f"Run scripts/generate_ct_bundle.py to build a package." + ) + data: dict = json.loads(path.read_text(encoding="utf-8")) + for name, terms in data.get("codelists", {}).items(): + codelists[name.upper()] = set(str(t) for t in terms) + loaded.append(pkg) + return cls(codelists, loaded) + + @classmethod + def load_default(cls) -> ControlledTerminology: + """Load the most recent bundled CT package automatically.""" + available = cls.available() + if not available: + return cls({}, []) + return cls.load([available[-1]]) + + @classmethod + def available(cls) -> list[str]: + """Return slugs for all bundled CT packages, sorted chronologically.""" + if not _CT_DIR.is_dir(): + return [] + return sorted(p.stem for p in _CT_DIR.glob("*.json")) + + def is_valid(self, codelist: str, value) -> bool: + """Whether `value` is a permitted submission value in `codelist`. + + `None` always returns `True` as null handling is a separate not-null rule. + """ + if value is None: + return True + terms = self._codelists.get(codelist.upper()) + if terms is None: + return True # unknown codelist: don't flag + return str(value) in terms + + def get_codelist(self, name: str) -> set[str] | None: + """Return the set of permitted values for a codelist, or `None` if unknown.""" + return self._codelists.get(name.upper()) + + def __contains__(self, codelist: str) -> bool: + return codelist.upper() in self._codelists + + def __repr__(self) -> str: + return ( + f"ControlledTerminology(packages={self.packages}, n_codelists={len(self._codelists)})" + ) From 7554ae8489c67a93293b732e44ac2c7d50f4a7c6 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:16:07 -0400 Subject: [PATCH 31/93] Add native CDISC conformance engine --- pointblank/metadata/_conformance/engine.py | 264 +++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 pointblank/metadata/_conformance/engine.py diff --git a/pointblank/metadata/_conformance/engine.py b/pointblank/metadata/_conformance/engine.py new file mode 100644 index 000000000..d8be259b2 --- /dev/null +++ b/pointblank/metadata/_conformance/engine.py @@ -0,0 +1,264 @@ +"""Native CDISC conformance engine. + +Runs bundled JSON rule catalogs against a collection of DataFrames using narwhals +expressions. No subprocesses, no external installs, no API calls at runtime. +""" + +from __future__ import annotations + +from typing import Any + +import narwhals as nw + +from pointblank.metadata._conformance.ct import ControlledTerminology +from pointblank.metadata._conformance.evaluator import EvaluationError, evaluate_conditions +from pointblank.metadata._conformance.operations import apply_operations +from pointblank.metadata._conformance.result import ( + STATUS_ERROR, + STATUS_FAIL, + STATUS_NOT_SUPPORTED, + STATUS_PASS, + NativeConformanceResult, + NativeRowFinding, + NativeRuleResult, +) +from pointblank.metadata._conformance.rule_loader import NativeRule, RuleLoader + +# Rule types handled in Phase 1. +_SUPPORTED_TYPES = { + "RECORD_CHECK", + "DATASET_METADATA_CHECK", + "DOMAIN_PRESENCE_CHECK", + "DATASET_CONTENTS_CHECK", +} + +# Maximum row-level findings to collect per rule (avoids blowing up memory on large datasets). +_MAX_FINDINGS = 100 + + +class NativeConformanceEngine: + """Run the bundled CDISC rule catalog against a collection of DataFrames. + + Parameters + ---------- + standard + The CDISC standard slug (e.g. `"sdtmig"`). + version + The standard version (e.g. `"3.4"`). + ct_packages + CT package slugs to load (e.g. `["sdtm-ct-2024-09-27"]`). If `None`, the most + recent bundled CT package is used automatically. + rule_types + Optional list of rule types to evaluate. Defaults to all Phase 1 supported types. + """ + + def __init__( + self, + standard: str, + version: str, + ct_packages: list[str] | None = None, + rule_types: list[str] | None = None, + ) -> None: + self.standard = standard + self.version = version + self._rules = RuleLoader.load(standard, version, rule_types=rule_types) + if ct_packages is None: + self._ct = ControlledTerminology.load_default() + else: + self._ct = ControlledTerminology.load(ct_packages) + + @property + def ct_packages(self) -> list[str]: + return self._ct.packages + + def run(self, datasets: dict[str, Any]) -> NativeConformanceResult: + """Evaluate all rules against `datasets`. + + Parameters + ---------- + datasets + Mapping of domain name (e.g. `"DM"`) to a Pandas or Polars DataFrame. + + Returns + ------- + NativeConformanceResult + """ + nw_datasets: dict[str, nw.DataFrame] = { + k.upper(): nw.from_native(v, eager_only=True) for k, v in datasets.items() + } + results: list[NativeRuleResult] = [] + for rule in self._rules: + result = self._evaluate_rule(rule, nw_datasets) + results.append(result) + return NativeConformanceResult( + standard=self.standard, + version=self.version, + ct_packages=self.ct_packages, + rule_results=results, + ) + + # ── Rule dispatch ───────────────────────────────────────────────────────── + + def _evaluate_rule( + self, rule: NativeRule, datasets: dict[str, nw.DataFrame] + ) -> NativeRuleResult: + if rule.rule_type not in _SUPPORTED_TYPES: + return NativeRuleResult( + rule_id=rule.core_id, + rule_type=rule.rule_type, + dataset="", + status=STATUS_NOT_SUPPORTED, + sensitivity=rule.sensitivity, + description=rule.description, + ) + + handler = { + "RECORD_CHECK": self._record_check, + "DATASET_METADATA_CHECK": self._dataset_metadata_check, + "DOMAIN_PRESENCE_CHECK": self._domain_presence_check, + "DATASET_CONTENTS_CHECK": self._dataset_contents_check, + }[rule.rule_type] + + try: + return handler(rule, datasets) + except Exception as exc: + # Determine which domain the rule targets (best effort). + domain = rule.domains[0] if rule.domains else "" + return NativeRuleResult( + rule_id=rule.core_id, + rule_type=rule.rule_type, + dataset=domain, + status=STATUS_ERROR, + sensitivity=rule.sensitivity, + description=rule.description, + message=str(exc), + ) + + # ── Rule type handlers ──────────────────────────────────────────────────── + + def _record_check( + self, rule: NativeRule, datasets: dict[str, nw.DataFrame] + ) -> NativeRuleResult: + """Per-row check: find rows where the condition tree evaluates to True (= violation).""" + target_domains = rule.domains or list(datasets.keys()) + all_findings: list[NativeRowFinding] = [] + n_issues = 0 + + for domain in target_domains: + df = datasets.get(domain.upper()) + if df is None: + continue + df = apply_operations(df, rule.operations, self._ct, datasets) + try: + mask = evaluate_conditions(df, rule.conditions) + except EvaluationError: + continue + failing_rows = [i for i, v in enumerate(mask.to_list()) if v] + n_issues += len(failing_rows) + for row_idx in failing_rows[:_MAX_FINDINGS]: + variables = df.columns[:5] + values = [str(df[c][row_idx]) for c in variables] + all_findings.append( + NativeRowFinding( + rule_id=rule.core_id, + dataset=domain, + row=row_idx, + variables=variables, + values=values, + message=rule.message, + ) + ) + + domain_label = ", ".join(target_domains) if target_domains else "" + return NativeRuleResult( + rule_id=rule.core_id, + rule_type=rule.rule_type, + dataset=domain_label, + status=STATUS_FAIL if n_issues > 0 else STATUS_PASS, + sensitivity=rule.sensitivity, + description=rule.description, + message=rule.message if n_issues > 0 else None, + n_issues=n_issues, + row_findings=all_findings, + ) + + def _dataset_contents_check( + self, rule: NativeRule, datasets: dict[str, nw.DataFrame] + ) -> NativeRuleResult: + """Dataset-level value constraint check. + + Like RECORD_CHECK but the result is at dataset granularity (not per-row). + """ + return self._record_check(rule, datasets) + + def _dataset_metadata_check( + self, rule: NativeRule, datasets: dict[str, nw.DataFrame] + ) -> NativeRuleResult: + """Dataset-level metadata check (column presence, sort keys, label, etc.). + + Conditions reference computed columns added by operations (e.g. `$USUBJID_present`). + """ + target_domains = rule.domains or list(datasets.keys()) + n_issues = 0 + first_failing_domain = "" + + for domain in target_domains: + df = datasets.get(domain.upper()) + if df is None: + continue + # Operations add the computed columns that conditions reference. + df = apply_operations(df, rule.operations, self._ct, datasets) + # For metadata checks the condition is evaluated once against a single-row summary + # DataFrame (all computed columns). If any operation added a False column, the + # condition fires. + try: + mask = evaluate_conditions(df, rule.conditions) + except EvaluationError: + continue + if any(mask.to_list()): + n_issues += 1 + if not first_failing_domain: + first_failing_domain = domain + + return NativeRuleResult( + rule_id=rule.core_id, + rule_type=rule.rule_type, + dataset=first_failing_domain or (target_domains[0] if target_domains else ""), + status=STATUS_FAIL if n_issues > 0 else STATUS_PASS, + sensitivity=rule.sensitivity, + description=rule.description, + message=rule.message if n_issues > 0 else None, + n_issues=n_issues, + ) + + def _domain_presence_check( + self, rule: NativeRule, datasets: dict[str, nw.DataFrame] + ) -> NativeRuleResult: + """Check that a required domain is present (or that a prohibited domain is absent).""" + params = rule.actions.get("params", {}) + required_domains: list[str] = params.get("required_domains", []) + prohibited_domains: list[str] = params.get("prohibited_domains", []) + present_domains = set(datasets.keys()) + + missing = [d for d in required_domains if d.upper() not in present_domains] + found_prohibited = [d for d in prohibited_domains if d.upper() in present_domains] + + issues = missing + found_prohibited + n_issues = len(issues) + message: str | None = None + if missing: + message = f"Required domain(s) missing: {', '.join(missing)}" + elif found_prohibited: + message = f"Prohibited domain(s) present: {', '.join(found_prohibited)}" + + domain_label = ", ".join(required_domains + prohibited_domains) or "" + return NativeRuleResult( + rule_id=rule.core_id, + rule_type=rule.rule_type, + dataset=domain_label, + status=STATUS_FAIL if n_issues > 0 else STATUS_PASS, + sensitivity=rule.sensitivity, + description=rule.description, + message=message, + n_issues=n_issues, + ) From 411a06b805e5f8b507286b2d950abd31b0ff0419 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:16:35 -0400 Subject: [PATCH 32/93] Add narwhals condition evaluator module --- pointblank/metadata/_conformance/evaluator.py | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 pointblank/metadata/_conformance/evaluator.py diff --git a/pointblank/metadata/_conformance/evaluator.py b/pointblank/metadata/_conformance/evaluator.py new file mode 100644 index 000000000..d4ebcd986 --- /dev/null +++ b/pointblank/metadata/_conformance/evaluator.py @@ -0,0 +1,134 @@ +"""Narwhals-based condition evaluator for the native conformance engine. + +Translates the JSON condition tree used in the rule catalog into narwhals boolean +expressions and evaluates them against a DataFrame, returning a boolean mask (one value +per row: True = rule condition is met = violation, False = no violation). + +Condition tree grammar +---------------------- +A condition tree is a dict with one of these shapes: + + {"all": [, ...]} all sub-conditions must hold (AND) + {"any": [, ...]} at least one sub-condition must hold (OR) + {"not": } invert the sub-condition + { leaf (single operator check) + "name": , + "operator": , + "value": , + } + +Supported operators (leaf nodes) +--------------------------------- +is_null, is_not_null, +equal_to, not_equal_to, +greater_than, greater_than_or_equal_to, +less_than, less_than_or_equal_to, +contains, not_contains, +starts_with, ends_with, +is_in, not_in, +matches_regex, +equal_to_column, not_equal_to_column, +""" + +from __future__ import annotations + +import re +from functools import reduce +from typing import Any + +import narwhals as nw + + +class EvaluationError(Exception): + """Raised when a condition references a column that does not exist.""" + + +def evaluate_conditions(df: nw.DataFrame, conditions: dict) -> nw.Series: + """Evaluate a condition tree against `df`. + + Returns a boolean Series (True = row matches the violation condition). + Returns an all-False Series when the condition tree is empty. + """ + if not conditions: + ns = nw.get_native_namespace(df) + return nw.new_series("_match", [False] * len(df), dtype=nw.Boolean, backend=ns) + try: + expr = _compile(conditions) + return df.select(expr.alias("_match"))["_match"] + except Exception as exc: + raise EvaluationError(str(exc)) from exc + + +def _compile(cond: dict) -> nw.Expr: + if "all" in cond: + sub = [_compile(c) for c in cond["all"]] + return reduce(lambda a, b: a & b, sub) + if "any" in cond: + sub = [_compile(c) for c in cond["any"]] + return reduce(lambda a, b: a | b, sub) + if "not" in cond: + return ~_compile(cond["not"]) + return _compile_leaf(cond) + + +def _compile_leaf(cond: dict) -> nw.Expr: + name: str = cond["name"] + op: str = cond["operator"] + value: Any = cond.get("value") + + col = nw.col(name) + + if op == "is_null": + return col.is_null() + if op == "is_not_null": + return ~col.is_null() + if op == "equal_to": + return col == value + if op == "not_equal_to": + return col != value + if op == "greater_than": + return col > value + if op == "greater_than_or_equal_to": + return col >= value + if op == "less_than": + return col < value + if op == "less_than_or_equal_to": + return col <= value + if op == "contains": + return col.cast(nw.String).str.contains(str(value)) + if op == "not_contains": + return ~col.cast(nw.String).str.contains(str(value)) + if op == "starts_with": + return col.cast(nw.String).str.starts_with(str(value)) + if op == "ends_with": + return col.cast(nw.String).str.ends_with(str(value)) + if op == "is_in": + terms = list(value) if not isinstance(value, list) else value + return col.is_in(terms) + if op == "not_in": + terms = list(value) if not isinstance(value, list) else value + return ~col.is_in(terms) + if op == "matches_regex": + return col.cast(nw.String).str.contains(str(value)) + if op == "equal_to_column": + return col == nw.col(str(value)) + if op == "not_equal_to_column": + return col != nw.col(str(value)) + + raise ValueError(f"Unknown operator: {op!r}") + + +# ── ISO 8601 date helpers (used by date-format operation) ───────────────────── + +# Allows YYYY, YYYY-MM, YYYY-MM-DD, YYYY-MM-DDTHH:MM, YYYY-MM-DDTHH:MM:SS, with optional +# timezone offset or Z. Partial dates are permitted by SDTM convention. +_ISO8601_RE = re.compile( + r"^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$" +) + + +def is_iso8601(value: str | None) -> bool: + """Return True if `value` is a non-null, non-empty ISO 8601 partial/complete datetime string.""" + if not value or not isinstance(value, str): + return False + return bool(_ISO8601_RE.match(value.strip())) From 3b11946521724208ca32538103c59c0a8d777a7e Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:17:37 -0400 Subject: [PATCH 33/93] Add native conformance operation handlers --- .../metadata/_conformance/operations.py | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 pointblank/metadata/_conformance/operations.py diff --git a/pointblank/metadata/_conformance/operations.py b/pointblank/metadata/_conformance/operations.py new file mode 100644 index 000000000..37ca0e43d --- /dev/null +++ b/pointblank/metadata/_conformance/operations.py @@ -0,0 +1,156 @@ +"""Operation implementations for the native conformance engine. + +Each operation pre-processes the target dataset, adding computed columns that the condition +evaluator can then reference. Operations are executed sequentially before conditions run. + +Computed column naming convention: `_pb__` (e.g. `_pb_SEX_valid`). +Rule catalog condition nodes reference these names as `{"name": "_pb_SEX_valid", ...}`. + +Registered operations +--------------------- +codelist_check -- _pb__valid (True = value in codelist or null) +consistency_check -- _pb__consistent (True = value matches dataset mode, or null) +iso8601_check -- _pb__iso8601 (True = valid ISO 8601 partial/full datetime or null) +unique_per_subject -- _pb__unique (True = value is unique within the USUBJID group) +column_presence -- _pb__present (True = column exists in the dataset, scalar broadcast) +""" + +from __future__ import annotations + +from collections import Counter +from typing import Any + +import narwhals as nw + +from pointblank.metadata._conformance.ct import ControlledTerminology +from pointblank.metadata._conformance.evaluator import is_iso8601 + + +def _new_bool_series(name: str, values: list[bool], df: nw.DataFrame) -> nw.Series: + ns = nw.get_native_namespace(df) + return nw.new_series(name, values, dtype=nw.Boolean, backend=ns) + + +def apply_operations( + df: nw.DataFrame, + operations: list[dict], + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], +) -> nw.DataFrame: + """Apply all operations to `df`, returning an enriched DataFrame.""" + for op in operations: + operator = op.get("operator", "") + params = op.get("params", {}) + handler = _REGISTRY.get(operator) + if handler is None: + continue + try: + df = handler(df, params, ct, datasets) + except Exception: + pass # a failing operation silently skips; conditions that reference its column won't fire + return df + + +# ── Operation handlers ──────────────────────────────────────────────────────── + + +def _op_codelist_check( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], +) -> nw.DataFrame: + """Add `_pb__valid` (True = value in codelist or null).""" + col: str = params["column"] + codelist: str = params["codelist"] + result_col = f"_pb_{col}_valid" + if col not in df.columns: + return df.with_columns(nw.lit(True).alias(result_col)) + terms = ct.get_codelist(codelist) + if terms is None: + return df.with_columns(nw.lit(True).alias(result_col)) + values = df[col].to_list() + mask = [True if v is None else (str(v) in terms) for v in values] + return df.with_columns(_new_bool_series(result_col, mask, df)) + + +def _op_consistency_check( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], +) -> nw.DataFrame: + """Add `_pb__consistent` (True = value equals the mode, or null).""" + col: str = params["column"] + result_col = f"_pb_{col}_consistent" + if col not in df.columns: + return df.with_columns(nw.lit(True).alias(result_col)) + values = [v for v in df[col].to_list() if v is not None] + if not values: + return df.with_columns(nw.lit(True).alias(result_col)) + expected = Counter(values).most_common(1)[0][0] + rows = df[col].to_list() + mask = [True if v is None else (v == expected) for v in rows] + return df.with_columns(_new_bool_series(result_col, mask, df)) + + +def _op_iso8601_check( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], +) -> nw.DataFrame: + """Add `_pb__iso8601` (True = valid ISO 8601 partial/complete datetime or null).""" + col: str = params["column"] + result_col = f"_pb_{col}_iso8601" + if col not in df.columns: + return df.with_columns(nw.lit(True).alias(result_col)) + values = df[col].to_list() + mask = [True if v is None else is_iso8601(str(v)) for v in values] + return df.with_columns(_new_bool_series(result_col, mask, df)) + + +def _op_unique_per_subject( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], +) -> nw.DataFrame: + """Add `_pb__unique` (True = value is unique within the USUBJID group).""" + col: str = params["column"] + result_col = f"_pb_{col}_unique" + if col not in df.columns or "USUBJID" not in df.columns: + return df.with_columns(nw.lit(True).alias(result_col)) + counts = ( + df.group_by(["USUBJID", col]) + .agg(nw.len().alias("_n")) + .filter(nw.col("_n") > 1) + .select(["USUBJID", col]) + ) + dup_pairs: set[tuple] = set(zip(counts["USUBJID"].to_list(), counts[col].to_list())) + rows_usubjid = df["USUBJID"].to_list() + rows_col = df[col].to_list() + mask = [(u, v) not in dup_pairs for u, v in zip(rows_usubjid, rows_col)] + return df.with_columns(_new_bool_series(result_col, mask, df)) + + +def _op_column_presence( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], +) -> nw.DataFrame: + """Add `_pb__present` broadcast scalar (True = column exists in the dataset).""" + col: str = params["column"] + result_col = f"_pb_{col}_present" + present = col in df.columns + return df.with_columns(nw.lit(present).alias(result_col)) + + +_REGISTRY: dict[str, Any] = { + "codelist_check": _op_codelist_check, + "consistency_check": _op_consistency_check, + "iso8601_check": _op_iso8601_check, + "unique_per_subject": _op_unique_per_subject, + "column_presence": _op_column_presence, +} From d9ebfcfa3f2a04e5b82a784b5d9fc6e6b8ab8bba Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:17:57 -0400 Subject: [PATCH 34/93] Add native conformance result dataclasses --- pointblank/metadata/_conformance/result.py | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 pointblank/metadata/_conformance/result.py diff --git a/pointblank/metadata/_conformance/result.py b/pointblank/metadata/_conformance/result.py new file mode 100644 index 000000000..6aa3419ca --- /dev/null +++ b/pointblank/metadata/_conformance/result.py @@ -0,0 +1,92 @@ +"""Result dataclasses for the native conformance engine.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +# Rule execution statuses +STATUS_PASS = "pass" +STATUS_FAIL = "fail" +STATUS_ERROR = "error" +STATUS_NOT_APPLICABLE = "not_applicable" +STATUS_NOT_SUPPORTED = "not_supported" + + +@dataclass +class NativeRowFinding: + """A single row-level finding produced by a rule.""" + + rule_id: str + dataset: str + row: int | None + variables: list[str] + values: list[Any] + message: str + + +@dataclass +class NativeRuleResult: + """The outcome of evaluating one rule against the dataset collection.""" + + rule_id: str + rule_type: str + dataset: str + status: str + sensitivity: str = "Error" + description: str = "" + message: str | None = None + n_issues: int = 0 + row_findings: list[NativeRowFinding] = field(default_factory=list) + + +@dataclass +class NativeConformanceResult: + """Aggregated results of a native conformance run.""" + + standard: str + version: str + ct_packages: list[str] + rule_results: list[NativeRuleResult] + + @property + def all_passed(self) -> bool: + return not any(r.status == STATUS_FAIL for r in self.rule_results) + + @property + def n_total_issues(self) -> int: + return sum(r.n_issues for r in self.rule_results) + + def status_counts(self) -> dict[str, int]: + counts: dict[str, int] = {} + for r in self.rule_results: + counts[r.status] = counts.get(r.status, 0) + 1 + return counts + + def rules(self, status: str | None = None) -> list[NativeRuleResult]: + if status is None: + return list(self.rule_results) + return [r for r in self.rule_results if r.status == status] + + def findings(self) -> list[NativeRowFinding]: + out: list[NativeRowFinding] = [] + for r in self.rule_results: + out.extend(r.row_findings) + return out + + def issues(self) -> list[dict]: + out: list[dict] = [] + for r in self.rule_results: + if r.n_issues > 0: + out.append( + { + "dataset": r.dataset, + "rule_id": r.rule_id, + "rule_type": r.rule_type, + "message": r.message or r.description, + "n_issues": r.n_issues, + "sensitivity": r.sensitivity, + "status": r.status, + } + ) + return out From 7470e56c68bbfbd3cde3dd117699a092bb337eaa Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:18:52 -0400 Subject: [PATCH 35/93] Add CDISC conformance rule loader --- .../metadata/_conformance/rule_loader.py | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 pointblank/metadata/_conformance/rule_loader.py diff --git a/pointblank/metadata/_conformance/rule_loader.py b/pointblank/metadata/_conformance/rule_loader.py new file mode 100644 index 000000000..4cb816cf4 --- /dev/null +++ b/pointblank/metadata/_conformance/rule_loader.py @@ -0,0 +1,137 @@ +"""Load and introspect bundled CDISC conformance rule catalogs.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +_DATA_DIR = Path(__file__).parent.parent.parent / "data" / "conformance" / "rules" + + +@dataclass +class NativeRule: + """A single conformance rule loaded from the JSON catalog.""" + + core_id: str + rule_type: str + executability: str + sensitivity: str + description: str + authority: str + standards: list[str] + classes: list[str] + domains: list[str] + operations: list[dict] + conditions: dict + actions: dict + datasets: list[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: dict) -> NativeRule: + return cls( + core_id=d["core_id"], + rule_type=d["rule_type"], + executability=d.get("executability", "Fully Executable"), + sensitivity=d.get("sensitivity", "Error"), + description=d.get("description", ""), + authority=d.get("authority", "CDISC"), + standards=d.get("standards", []), + classes=d.get("classes", []), + domains=d.get("domains", []), + datasets=d.get("datasets", []), + operations=d.get("operations", []), + conditions=d.get("conditions", {}), + actions=d.get("actions", {}), + ) + + @property + def message(self) -> str: + return self.actions.get("params", {}).get("message", self.description) + + def applies_to_domain(self, domain: str) -> bool: + """Whether this rule applies to the given domain (case-insensitive).""" + if not self.domains: + return True + return domain.upper() in {d.upper() for d in self.domains} + + def applies_to_standard(self, standard: str) -> bool: + if not self.standards: + return True + return standard.lower() in {s.lower() for s in self.standards} + + +class RuleLoader: + """Load and introspect bundled conformance rule catalogs.""" + + @staticmethod + def catalog_path(standard: str, version: str) -> Path: + slug = f"{standard.lower()}-{version.replace('.', '-')}" + return _DATA_DIR / f"{slug}.json" + + @classmethod + def available(cls) -> list[tuple[str, str]]: + """Return (standard, version) pairs for all bundled catalogs.""" + if not _DATA_DIR.is_dir(): + return [] + pairs: list[tuple[str, str]] = [] + for p in sorted(_DATA_DIR.glob("*.json")): + try: + data = json.loads(p.read_text(encoding="utf-8")) + std = data.get("standard", "") + ver = data.get("version", "") + if std and ver: + pairs.append((std, ver)) + except Exception: + pass + return pairs + + @classmethod + def load( + cls, + standard: str, + version: str, + rule_types: list[str] | None = None, + ) -> list[NativeRule]: + """Load rules from the bundled catalog for the given standard/version. + + Parameters + ---------- + standard + The CDISC standard slug (e.g. `"sdtmig"`). + version + The standard version (e.g. `"3.4"`). + rule_types + Optional list of rule types to load (e.g. `["RECORD_CHECK"]`). If `None`, all rule types + in the catalog are returned. + + Raises + ------ + FileNotFoundError + If no catalog exists for the given standard and version. + """ + path = cls.catalog_path(standard, version) + if not path.exists(): + available = cls.available() + avail_str = ", ".join(f"{s} {v}" for s, v in available) or "(none)" + raise FileNotFoundError( + f"No bundled rule catalog for {standard} {version}. " + f"Available: {avail_str}. " + f"Run scripts/generate_rule_catalog.py to build a catalog." + ) + data: dict = json.loads(path.read_text(encoding="utf-8")) + rules = [NativeRule.from_dict(r) for r in data.get("rules", [])] + if rule_types is not None: + rt_set = set(rule_types) + rules = [r for r in rules if r.rule_type in rt_set] + return rules + + @classmethod + def catalog_metadata(cls, standard: str, version: str) -> dict[str, Any]: + """Return the catalog header (generated, checksum, source, etc.) without loading rules.""" + path = cls.catalog_path(standard, version) + if not path.exists(): + raise FileNotFoundError(f"No catalog for {standard} {version}.") + data: dict = json.loads(path.read_text(encoding="utf-8")) + return {k: v for k, v in data.items() if k != "rules"} From b7541b34122852b2c9097b041ba1779b00aab581 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:19:22 -0400 Subject: [PATCH 36/93] Add native rule-based conformance engine --- pointblank/metadata/_submission.py | 176 +++++++++++++++++++++++++---- 1 file changed, 152 insertions(+), 24 deletions(-) diff --git a/pointblank/metadata/_submission.py b/pointblank/metadata/_submission.py index 68f951e48..bdb9cbef8 100644 --- a/pointblank/metadata/_submission.py +++ b/pointblank/metadata/_submission.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: from pointblank.metadata._cdisc_core import CoreFinding, CoreRuleResult, ParsedCoreReport + from pointblank.metadata._conformance.result import NativeConformanceResult, NativeRowFinding, NativeRuleResult from pointblank.metadata._types import MetadataPackage from pointblank.validate import Validate @@ -405,6 +406,7 @@ def validate_conformance( *, standard: str | None = None, version: str | None = None, + ct_packages: list[str] | None = None, controlled_terminology: str | Sequence[str] | None = None, core: str | Sequence[str] | None = None, core_cwd: str | Path | None = None, @@ -477,8 +479,8 @@ def validate_conformance( A native-form report (per-dataset validations) or a CORE-form report, depending on `engine`. """ - if engine not in ("native", "core"): - raise ValueError(f"engine must be 'native' or 'core', got {engine!r}.") + if engine not in ("native", "core", "validate"): + raise ValueError(f"engine must be 'native', 'validate', or 'core', got {engine!r}.") if engine == "core": return self._run_core_conformance( @@ -492,6 +494,18 @@ def validate_conformance( workdir=workdir, ) + # engine="validate" always uses the Validate-based approach (cross-dataset checks). + if engine != "validate": + # Try the rule-based native engine first (requires a bundled catalog). + std = standard or self.standard + ver = version or self.standard_version + rules_report = self._run_rules_conformance( + agency=agency, standard=std, version=ver, ct_packages=ct_packages + ) + if rules_report is not None: + return rules_report + + # Fallback (or engine="validate"): Validate-based approach with cross-dataset checks. validations: dict[str, Validate] = {} for name in self.domains: @@ -506,6 +520,26 @@ def validate_conformance( return ConformanceReport(validations=validations, package=self, agency=agency) + def _run_rules_conformance( + self, + agency: str | None, + standard: str, + version: str, + ct_packages: list[str] | None, + ) -> ConformanceReport | None: + """Run the native rule-based engine; returns None if no catalog is bundled.""" + from pointblank.metadata._conformance.engine import NativeConformanceEngine + from pointblank.metadata._conformance.rule_loader import RuleLoader + + if not RuleLoader.catalog_path(standard, version).exists(): + return None + + engine = NativeConformanceEngine( + standard=standard, version=version, ct_packages=ct_packages + ) + result = engine.run(self.datasets) + return ConformanceReport(native_result=result, package=self, agency=agency) + def _run_core_conformance( self, agency: str | None, @@ -906,6 +940,7 @@ class ConformanceReport: package: SubmissionPackage | None = None agency: str | None = None core: ParsedCoreReport | None = None + native_result: NativeConformanceResult | None = None # ── Construction ───────────────────────────────────────────────────────── @@ -943,6 +978,11 @@ def is_core(self) -> bool: """Whether this report wraps CDISC CORE engine results (vs. native validations).""" return self.core is not None + @property + def is_rules(self) -> bool: + """Whether this report was produced by the native rule-based conformance engine.""" + return self.native_result is not None + def all_passed(self) -> bool: """Whether the run reported no conformance failures. @@ -952,6 +992,8 @@ def all_passed(self) -> bool: """ if self.is_core: return self.core.all_passed + if self.is_rules: + return self.native_result.all_passed return all(v.all_passed() for v in self.validations.values()) def __getitem__(self, name: str) -> Validate: @@ -983,6 +1025,7 @@ def summary(self) -> dict: "standard": core.standard, "version": core.version, "engine_version": core.engine_version, + "engine": "core", "n_rules": len(core.rules), "status_counts": core.status_counts(), "n_issues": core.n_total_issues, @@ -990,6 +1033,19 @@ def summary(self) -> dict: "all_passed": core.all_passed, } + if self.is_rules: + nr = self.native_result + return { + "standard": nr.standard, + "version": nr.version, + "engine": "native", + "ct_packages": nr.ct_packages, + "n_rules": len(nr.rule_results), + "status_counts": nr.status_counts(), + "n_issues": nr.n_total_issues, + "all_passed": nr.all_passed, + } + out: dict[str, dict] = {} for name, v in self.validations.items(): steps = v.validation_info @@ -1026,6 +1082,9 @@ def issues(self, severity: str | None = None, status: str | None = None) -> list For a **CORE** report, one dict per (dataset, rule) with reported issues, with keys `dataset`, `rule_id`, `message`, `issues` (count), and `status`. """ + if self.is_rules: + return self.native_result.issues() + if self.is_core: # Look up each rule's run status by rule id. status_by_rule = {r.rule_id: r.status for r in self.core.rules} @@ -1073,42 +1132,47 @@ def issues(self, severity: str | None = None, status: str | None = None) -> list ) return issues - def findings(self) -> list[CoreFinding]: - """Return the row-level CORE findings (CORE reports only). + def findings(self): + """Return the row-level findings. - Returns - ------- - list[CoreFinding] - The row-level findings from CORE's `Issue_Details`, or an empty list for native - reports. + For CORE reports, returns `CoreFinding` objects from CORE's `Issue_Details`. + For native rule reports, returns `NativeRowFinding` objects. + For Validate-based native reports, returns an empty list. """ - return list(self.core.findings) if self.is_core else [] + if self.is_core: + return list(self.core.findings) + if self.is_rules: + return self.native_result.findings() + return [] + + def rules(self, status: str | None = None): + """Return the per-rule run results. - def rules(self, status: str | None = None) -> list[CoreRuleResult]: - """Return the per-rule run results (CORE reports only). + For CORE reports, returns `CoreRuleResult` objects. + For native rule reports, returns `NativeRuleResult` objects. + For Validate-based native reports, returns an empty list. Parameters ---------- status - Optional status filter (e.g. `"SUCCESS"`, `"SKIPPED"`, `"ISSUE REPORTED"`, - `"EXECUTION ERROR"`). If `None`, all rules are returned. - - Returns - ------- - list[CoreRuleResult] - The per-rule results from CORE's `Rules_Report`, or an empty list for native reports. + Optional status filter. For CORE: e.g. `"SUCCESS"`, `"SKIPPED"`. For native rules: + `"pass"`, `"fail"`, `"error"`, `"not_applicable"`, `"not_supported"`. """ - if not self.is_core: - return [] - if status is None: - return list(self.core.rules) - return [r for r in self.core.rules if r.status == status] + if self.is_core: + if status is None: + return list(self.core.rules) + return [r for r in self.core.rules if r.status == status] + if self.is_rules: + return self.native_result.rules(status=status) + return [] @property def n_datasets(self) -> int: """Number of datasets validated.""" if self.is_core: return len(self.core.datasets) + if self.is_rules and self.package is not None: + return len(self.package.datasets) return len(self.validations) def to_json(self, path: str | Path) -> Path: @@ -1273,6 +1337,29 @@ def to_excel(self, path: str | Path) -> Path: pd.DataFrame( [{"Key": k, "Value": v} for k, v in core.details.items()] ).to_excel(writer, sheet_name="Conformance_Details", index=False) + elif self.is_rules: + nr = self.native_result + pd.DataFrame( + [ + { + "Rule ID": r.rule_id, + "Rule Type": r.rule_type, + "Dataset": r.dataset, + "Status": r.status, + "Sensitivity": r.sensitivity, + "Issues": r.n_issues, + "Message": r.message or r.description, + } + for r in nr.rule_results + ] + ).to_excel(writer, sheet_name="Rules_Report", index=False) + issues = self.issues() + if issues: + pd.DataFrame(issues).to_excel(writer, sheet_name="Issues", index=False) + s = self.summary() + pd.DataFrame( + [{"Key": k, "Value": str(v)} for k, v in s.items()] + ).to_excel(writer, sheet_name="Summary", index=False) else: issues = self.issues() if issues: @@ -1286,6 +1373,34 @@ def to_excel(self, path: str | Path) -> Path: def _repr_html_(self) -> str: agency = f" — agency: {self.agency}" if self.agency else "" + if self.is_rules: + nr = self.native_result + parts = [f"

CDISC Conformance Report (Native Rules){agency}

"] + status = "PASS" if nr.all_passed else "FAIL" + parts.append( + f"

{nr.standard} {nr.version} — " + f"{status}

" + ) + counts = nr.status_counts() + parts.append("
    ") + for st, n in sorted(counts.items()): + parts.append(f"
  • {st}: {n}
  • ") + parts.append(f"
  • Total issues: {nr.n_total_issues}
  • ") + parts.append("
") + failing = [r for r in nr.rule_results if r.n_issues > 0] + if failing: + parts.append( + "" + "" + ) + for r in failing: + parts.append( + f"" + f"" + ) + parts.append("
DatasetRuleIssuesMessage
{r.dataset}{r.rule_id}{r.n_issues}{r.message or r.description}
") + return "\n".join(parts) + if self.is_core: core = self.core parts = [f"

CDISC Conformance Report (CORE){agency}

"] @@ -1323,6 +1438,19 @@ def _repr_html_(self) -> str: return "\n".join(parts) def __repr__(self) -> str: + if self.is_rules: + nr = self.native_result + lines = ["ConformanceReport (Native Rules)"] + if self.agency: + lines.append(f" Agency: {self.agency}") + lines.append(f" {nr.standard} {nr.version}") + status = "PASS" if nr.all_passed else "FAIL" + counts = nr.status_counts() + counts_str = ", ".join(f"{k}={v}" for k, v in sorted(counts.items())) + lines.append(f" {len(nr.rule_results)} rules ({counts_str})") + lines.append(f" {nr.n_total_issues} issues — {status}") + return "\n".join(lines) + if self.is_core: core = self.core lines = ["ConformanceReport (CORE)"] From ef5462b6bc29c44d29e361c1186263be7ea2d2ed Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:19:34 -0400 Subject: [PATCH 37/93] Create __init__.py --- pointblank/metadata/_conformance/__init__.py | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 pointblank/metadata/_conformance/__init__.py diff --git a/pointblank/metadata/_conformance/__init__.py b/pointblank/metadata/_conformance/__init__.py new file mode 100644 index 000000000..f0d3eb030 --- /dev/null +++ b/pointblank/metadata/_conformance/__init__.py @@ -0,0 +1,35 @@ +"""Native CDISC conformance rule engine (Phase 1). + +This package implements rule-based CDISC conformance validation without any external subprocess, +Docker image, or API calls at runtime. Rules are loaded from bundled JSON catalogs; controlled +terminology is loaded from bundled JSON packages. + +Public surface +-------------- +NativeConformanceEngine -- run a rule catalog against a dataset collection +NativeConformanceResult -- the result of a native run +NativeRuleResult -- per-rule result +NativeRowFinding -- row-level finding within a rule result +RuleLoader -- load / introspect bundled rule catalogs +ControlledTerminology -- load / query bundled CT packages +""" + +from __future__ import annotations + +from pointblank.metadata._conformance.engine import NativeConformanceEngine +from pointblank.metadata._conformance.result import ( + NativeConformanceResult, + NativeRowFinding, + NativeRuleResult, +) +from pointblank.metadata._conformance.rule_loader import RuleLoader +from pointblank.metadata._conformance.ct import ControlledTerminology + +__all__ = [ + "NativeConformanceEngine", + "NativeConformanceResult", + "NativeRowFinding", + "NativeRuleResult", + "RuleLoader", + "ControlledTerminology", +] From 59eaf81c3c44253086daf8b20af3774210a3af51 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:19:47 -0400 Subject: [PATCH 38/93] Update native report tests for rules engine --- tests/test_cdisc_core.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/test_cdisc_core.py b/tests/test_cdisc_core.py index cfb12758b..3f17d3d67 100644 --- a/tests/test_cdisc_core.py +++ b/tests/test_cdisc_core.py @@ -273,11 +273,12 @@ def test_trimmed_fixture_is_internally_consistent(trimmed_report): # ── Native/CORE separation ───────────────────────────────────────────────────── -def test_native_report_findings_rules_empty(): - # A native (non-CORE) report returns empty CORE accessors and is_core False. +def test_native_rules_report_properties(): + # A native rules-engine report: is_core False, is_rules True, has rule results. import pandas as pd import pointblank as pb + from pointblank.metadata._conformance.result import NativeRuleResult dm = pd.DataFrame( { @@ -292,8 +293,13 @@ def test_native_report_findings_rules_empty(): ) rep = pb.SubmissionPackage(datasets={"DM": dm}).validate_conformance() assert rep.is_core is False - assert rep.findings() == [] - assert rep.rules() == [] + assert rep.is_rules is True + # findings() returns a list (may be empty for clean data) + assert isinstance(rep.findings(), list) + # rules() returns NativeRuleResult objects + rules = rep.rules() + assert len(rules) > 0 + assert all(isinstance(r, NativeRuleResult) for r in rules) # ── Dataset materialization (_write_xpt / _materialize_datasets) ──────────────── @@ -698,9 +704,13 @@ def test_to_json_native(tmp_path): dest = rep.to_json(tmp_path / "native_report.json") assert dest.exists() data = json.loads(dest.read_text()) + # Rules-engine native report has flat summary and issues list. assert "summary" in data assert "issues" in data - assert "DM" in data["summary"] + s = data["summary"] + assert s["engine"] == "native" + assert "standard" in s + assert "n_rules" in s def test_to_excel_core_sheets(tmp_path, full_report): @@ -744,6 +754,8 @@ def test_to_excel_native(tmp_path): dest = rep.to_excel(tmp_path / "native_report.xlsx") assert dest.exists() wb = openpyxl.load_workbook(dest) + # Rules-engine native report produces Rules_Report and Summary sheets. + assert "Rules_Report" in wb.sheetnames assert "Summary" in wb.sheetnames From b9307e5ab480468266dceba896266982843da074 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:20:34 -0400 Subject: [PATCH 39/93] Add tests for native conformance engine --- tests/test_native_conformance.py | 546 +++++++++++++++++++++++++++++++ 1 file changed, 546 insertions(+) create mode 100644 tests/test_native_conformance.py diff --git a/tests/test_native_conformance.py b/tests/test_native_conformance.py new file mode 100644 index 000000000..80fea5968 --- /dev/null +++ b/tests/test_native_conformance.py @@ -0,0 +1,546 @@ +"""Tests for the native CDISC conformance rule engine.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd +import polars as pl +import pytest + +import pointblank as pb +from pointblank.metadata._conformance import ( + ControlledTerminology, + NativeConformanceEngine, + NativeConformanceResult, + NativeRowFinding, + NativeRuleResult, + RuleLoader, +) +from pointblank.metadata._conformance.evaluator import evaluate_conditions, is_iso8601 +from pointblank.metadata._conformance.operations import apply_operations +from pointblank.metadata._conformance.result import STATUS_FAIL, STATUS_PASS +import narwhals as nw + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + + +def _clean_dm(backend="polars"): + data = { + "STUDYID": ["S001", "S001"], + "DOMAIN": ["DM", "DM"], + "USUBJID": ["S001-001", "S001-002"], + "SUBJID": ["001", "002"], + "SEX": ["M", "F"], + "RACE": ["WHITE", "ASIAN"], + "COUNTRY": ["USA", "GBR"], + "DMDTC": ["2024-01-01", "2024-01-02"], + } + if backend == "pandas": + return pd.DataFrame(data) + return pl.DataFrame(data) + + +@pytest.fixture +def ct(): + return ControlledTerminology.load_default() + + +@pytest.fixture +def engine(): + return NativeConformanceEngine("sdtmig", "3.4") + + +@pytest.fixture +def clean_result(engine): + return engine.run({"DM": _clean_dm()}) + + +# ── RuleLoader ──────────────────────────────────────────────────────────────── + + +def test_rule_loader_loads_sdtmig_34(): + rules = RuleLoader.load("sdtmig", "3.4") + assert len(rules) >= 20 + + +def test_rule_loader_rule_types_coverage(): + rules = RuleLoader.load("sdtmig", "3.4") + types = {r.rule_type for r in rules} + assert "RECORD_CHECK" in types + assert "DATASET_CONTENTS_CHECK" in types + assert "DATASET_METADATA_CHECK" in types + assert "DOMAIN_PRESENCE_CHECK" in types + + +def test_rule_loader_available(): + available = RuleLoader.available() + assert ("sdtmig", "3.4") in available + + +def test_rule_loader_missing_raises(): + with pytest.raises(FileNotFoundError, match="No bundled rule catalog"): + RuleLoader.load("imaginary", "99.0") + + +def test_rule_loader_filter_by_type(): + rules = RuleLoader.load("sdtmig", "3.4", rule_types=["RECORD_CHECK"]) + assert all(r.rule_type == "RECORD_CHECK" for r in rules) + + +def test_rule_loader_catalog_metadata(): + meta = RuleLoader.catalog_metadata("sdtmig", "3.4") + assert "standard" in meta + assert meta["standard"] == "sdtmig" + assert "checksum" in meta + + +# ── ControlledTerminology ───────────────────────────────────────────────────── + + +def test_ct_load_default(ct): + assert len(ct.packages) == 1 + assert "SEX" in ct + + +def test_ct_valid_term(ct): + assert ct.is_valid("SEX", "M") + assert ct.is_valid("SEX", "F") + + +def test_ct_invalid_term(ct): + assert not ct.is_valid("SEX", "Q") + + +def test_ct_null_passes(ct): + # Null values pass by convention (separate not-null rule handles them). + assert ct.is_valid("SEX", None) + + +def test_ct_unknown_codelist_passes(ct): + assert ct.is_valid("NONEXISTENT_CODELIST", "ANYTHING") + + +def test_ct_available(): + available = ControlledTerminology.available() + assert len(available) >= 1 + + +def test_ct_missing_package_raises(): + with pytest.raises(FileNotFoundError): + ControlledTerminology.load(["no-such-package-2099-01-01"]) + + +# ── Evaluator ───────────────────────────────────────────────────────────────── + + +def _nw_df(data: dict) -> nw.DataFrame: + return nw.from_native(pl.DataFrame(data), eager_only=True) + + +def test_evaluator_is_null(): + df = _nw_df({"x": [1, None, 3]}) + mask = evaluate_conditions(df, {"all": [{"name": "x", "operator": "is_null", "value": None}]}) + assert mask.to_list() == [False, True, False] + + +def test_evaluator_is_not_null(): + df = _nw_df({"x": [1, None, 3]}) + mask = evaluate_conditions( + df, {"all": [{"name": "x", "operator": "is_not_null", "value": None}]} + ) + assert mask.to_list() == [True, False, True] + + +def test_evaluator_equal_to(): + df = _nw_df({"x": [1, 2, 3]}) + mask = evaluate_conditions(df, {"all": [{"name": "x", "operator": "equal_to", "value": 2}]}) + assert mask.to_list() == [False, True, False] + + +def test_evaluator_not_equal_to(): + df = _nw_df({"x": [1, 2, 3]}) + mask = evaluate_conditions(df, {"all": [{"name": "x", "operator": "not_equal_to", "value": 2}]}) + assert mask.to_list() == [True, False, True] + + +def test_evaluator_greater_than(): + df = _nw_df({"x": [1, 5, 3]}) + mask = evaluate_conditions(df, {"all": [{"name": "x", "operator": "greater_than", "value": 3}]}) + assert mask.to_list() == [False, True, False] + + +def test_evaluator_is_in(): + df = _nw_df({"x": ["A", "B", "C"]}) + mask = evaluate_conditions( + df, {"all": [{"name": "x", "operator": "is_in", "value": ["A", "C"]}]} + ) + assert mask.to_list() == [True, False, True] + + +def test_evaluator_not_in(): + df = _nw_df({"x": ["A", "B", "C"]}) + mask = evaluate_conditions( + df, {"all": [{"name": "x", "operator": "not_in", "value": ["A", "C"]}]} + ) + assert mask.to_list() == [False, True, False] + + +def test_evaluator_contains(): + df = _nw_df({"x": ["hello world", "foo", "world"]}) + mask = evaluate_conditions( + df, {"all": [{"name": "x", "operator": "contains", "value": "world"}]} + ) + assert mask.to_list() == [True, False, True] + + +def test_evaluator_any_combinator(): + df = _nw_df({"x": [1, 2, 3], "y": [10, 20, 30]}) + mask = evaluate_conditions( + df, + { + "any": [ + {"name": "x", "operator": "equal_to", "value": 1}, + {"name": "y", "operator": "equal_to", "value": 30}, + ] + }, + ) + assert mask.to_list() == [True, False, True] + + +def test_evaluator_not_combinator(): + df = _nw_df({"x": [1, 2, 3]}) + mask = evaluate_conditions(df, {"not": {"name": "x", "operator": "equal_to", "value": 2}}) + assert mask.to_list() == [True, False, True] + + +def test_evaluator_empty_conditions_returns_all_false(): + df = _nw_df({"x": [1, 2, 3]}) + mask = evaluate_conditions(df, {}) + assert all(not v for v in mask.to_list()) + + +def test_evaluator_equal_to_column(): + df = _nw_df({"a": [1, 2, 3], "b": [1, 99, 3]}) + mask = evaluate_conditions( + df, {"all": [{"name": "a", "operator": "equal_to_column", "value": "b"}]} + ) + assert mask.to_list() == [True, False, True] + + +def test_iso8601_valid(): + assert is_iso8601("2024-01-15") + assert is_iso8601("2024-01") + assert is_iso8601("2024") + assert is_iso8601("2024-01-15T10:30:00") + assert is_iso8601("2024-01-15T10:30:00Z") + assert is_iso8601("2024-01-15T10:30:00+05:30") + + +def test_iso8601_invalid(): + assert not is_iso8601("01/15/2024") + assert not is_iso8601("not-a-date") + assert not is_iso8601("") + assert not is_iso8601(None) + + +# ── Operations ──────────────────────────────────────────────────────────────── + + +def test_op_codelist_check_valid(ct): + df = _nw_df({"SEX": ["M", "F", "Q"]}) + result = apply_operations( + df, [{"operator": "codelist_check", "params": {"column": "SEX", "codelist": "SEX"}}], ct, {} + ) + assert "_pb_SEX_valid" in result.columns + assert result["_pb_SEX_valid"].to_list() == [True, True, False] + + +def test_op_codelist_check_null_passes(ct): + df = _nw_df({"SEX": ["M", None, "F"]}) + result = apply_operations( + df, [{"operator": "codelist_check", "params": {"column": "SEX", "codelist": "SEX"}}], ct, {} + ) + assert result["_pb_SEX_valid"].to_list() == [True, True, True] + + +def test_op_codelist_check_missing_column(ct): + df = _nw_df({"OTHER": ["A"]}) + result = apply_operations( + df, [{"operator": "codelist_check", "params": {"column": "SEX", "codelist": "SEX"}}], ct, {} + ) + assert result["_pb_SEX_valid"].to_list() == [True] + + +def test_op_consistency_check_consistent(ct): + df = _nw_df({"STUDYID": ["S001", "S001", "S001"]}) + result = apply_operations( + df, [{"operator": "consistency_check", "params": {"column": "STUDYID"}}], ct, {} + ) + assert all(result["_pb_STUDYID_consistent"].to_list()) + + +def test_op_consistency_check_inconsistent(ct): + df = _nw_df({"STUDYID": ["S001", "S001", "S002"]}) + result = apply_operations( + df, [{"operator": "consistency_check", "params": {"column": "STUDYID"}}], ct, {} + ) + vals = result["_pb_STUDYID_consistent"].to_list() + assert vals[2] is False # S002 is the outlier + + +def test_op_iso8601_check_valid(ct): + df = _nw_df({"DTC": ["2024-01-01", "not-a-date", None]}) + result = apply_operations( + df, [{"operator": "iso8601_check", "params": {"column": "DTC"}}], ct, {} + ) + vals = result["_pb_DTC_iso8601"].to_list() + assert vals == [True, False, True] + + +def test_op_column_presence_present(ct): + df = _nw_df({"USUBJID": ["U1"]}) + result = apply_operations( + df, [{"operator": "column_presence", "params": {"column": "USUBJID"}}], ct, {} + ) + assert result["_pb_USUBJID_present"].to_list() == [True] + + +def test_op_column_presence_absent(ct): + df = _nw_df({"OTHER": ["X"]}) + result = apply_operations( + df, [{"operator": "column_presence", "params": {"column": "USUBJID"}}], ct, {} + ) + assert result["_pb_USUBJID_present"].to_list() == [False] + + +def test_op_unknown_operator_skipped(ct): + df = _nw_df({"x": [1]}) + # Should not raise; unknown operators are silently skipped. + result = apply_operations(df, [{"operator": "nonexistent_op", "params": {}}], ct, {}) + assert result.columns == ["x"] + + +# ── NativeConformanceEngine: clean data ─────────────────────────────────────── + + +def test_engine_clean_dm_all_pass(clean_result): + assert clean_result.all_passed + + +def test_engine_clean_dm_zero_issues(clean_result): + assert clean_result.n_total_issues == 0 + + +def test_engine_rule_count(clean_result): + assert len(clean_result.rule_results) == 30 + + +def test_engine_result_types(clean_result): + assert isinstance(clean_result, NativeConformanceResult) + assert all(isinstance(r, NativeRuleResult) for r in clean_result.rule_results) + + +def test_engine_status_counts(clean_result): + counts = clean_result.status_counts() + assert STATUS_PASS in counts + assert counts.get(STATUS_FAIL, 0) == 0 + + +def test_engine_findings_empty_for_clean_data(clean_result): + assert clean_result.findings() == [] + + +# ── NativeConformanceEngine: violations ─────────────────────────────────────── + + +def test_engine_detects_invalid_sex(engine): + dm = pl.DataFrame( + { + "STUDYID": ["S1"], + "DOMAIN": ["DM"], + "USUBJID": ["U1"], + "SUBJID": ["1"], + "SEX": ["Q"], + "COUNTRY": ["USA"], + "DMDTC": ["2024-01-01"], + } + ) + result = engine.run({"DM": dm}) + ids = {r.rule_id for r in result.rule_results if r.status == STATUS_FAIL} + assert "SDTM-007" in ids + + +def test_engine_detects_invalid_country(engine): + dm = pl.DataFrame( + { + "STUDYID": ["S1"], + "DOMAIN": ["DM"], + "USUBJID": ["U1"], + "SUBJID": ["1"], + "COUNTRY": ["XYZ"], + "DMDTC": ["2024-01-01"], + } + ) + result = engine.run({"DM": dm}) + ids = {r.rule_id for r in result.rule_results if r.status == STATUS_FAIL} + assert "SDTM-009" in ids + + +def test_engine_detects_bad_date(engine): + dm = pl.DataFrame( + { + "STUDYID": ["S1"], + "DOMAIN": ["DM"], + "USUBJID": ["U1"], + "SUBJID": ["1"], + "DMDTC": ["not-a-date"], + } + ) + result = engine.run({"DM": dm}) + ids = {r.rule_id for r in result.rule_results if r.status == STATUS_FAIL} + assert "SDTM-010" in ids + + +def test_engine_detects_null_usubjid(engine): + dm = pl.DataFrame({"STUDYID": ["S1", "S1"], "DOMAIN": ["DM", "DM"], "USUBJID": ["U1", None]}) + result = engine.run({"DM": dm}) + ids = {r.rule_id for r in result.rule_results if r.status == STATUS_FAIL} + assert "SDTM-004" in ids + + +def test_engine_detects_missing_dm_domain(engine): + ae = pl.DataFrame({"STUDYID": ["S1"], "DOMAIN": ["AE"], "USUBJID": ["U1"]}) + result = engine.run({"AE": ae}) + ids = {r.rule_id for r in result.rule_results if r.status == STATUS_FAIL} + assert "SDTM-001" in ids + + +def test_engine_detects_inconsistent_studyid(engine): + dm = pl.DataFrame({"STUDYID": ["S1", "S2"], "DOMAIN": ["DM", "DM"], "USUBJID": ["U1", "U2"]}) + result = engine.run({"DM": dm}) + ids = {r.rule_id for r in result.rule_results if r.status == STATUS_FAIL} + assert "SDTM-005" in ids + + +def test_engine_row_findings_populated(engine): + dm = pl.DataFrame( + {"STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], "SUBJID": ["1"], "SEX": ["Q"]} + ) + result = engine.run({"DM": dm}) + findings = result.findings() + assert len(findings) > 0 + f = findings[0] + assert isinstance(f, NativeRowFinding) + assert f.rule_id == "SDTM-007" + assert f.row == 0 + + +def test_engine_rules_status_filter(clean_result): + passing = clean_result.rules(status=STATUS_PASS) + assert all(r.status == STATUS_PASS for r in passing) + + +def test_engine_issues_list(engine): + dm = pl.DataFrame( + {"STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], "SUBJID": ["1"], "SEX": ["Q"]} + ) + result = engine.run({"DM": dm}) + issues = result.issues() + assert len(issues) > 0 + issue = next(i for i in issues if i["rule_id"] == "SDTM-007") + assert issue["n_issues"] == 1 + assert issue["status"] == STATUS_FAIL + + +# ── Pandas backend ──────────────────────────────────────────────────────────── + + +def test_engine_works_with_pandas(engine): + dm = pd.DataFrame( + {"STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], "SUBJID": ["1"], "SEX": ["Q"]} + ) + result = engine.run({"DM": dm}) + ids = {r.rule_id for r in result.rule_results if r.status == STATUS_FAIL} + assert "SDTM-007" in ids + + +# ── SubmissionPackage integration ───────────────────────────────────────────── + + +def test_submission_package_uses_rules_engine(): + pkg = pb.SubmissionPackage( + datasets={"DM": _clean_dm()}, standard="sdtmig", standard_version="3.4" + ) + report = pkg.validate_conformance() + assert report.is_rules + assert not report.is_core + + +def test_submission_package_summary_has_engine_key(): + pkg = pb.SubmissionPackage(datasets={"DM": _clean_dm()}) + report = pkg.validate_conformance() + s = report.summary() + assert s["engine"] == "native" + assert "n_rules" in s + assert "n_issues" in s + + +def test_submission_package_dirty_data_fails(): + dirty = pl.DataFrame( + {"STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], "SUBJID": ["1"], "SEX": ["BAD"]} + ) + pkg = pb.SubmissionPackage(datasets={"DM": dirty}) + report = pkg.validate_conformance() + assert not report.all_passed() + assert len(report.issues()) > 0 + + +def test_submission_package_repr_shows_native_rules(): + pkg = pb.SubmissionPackage(datasets={"DM": _clean_dm()}) + report = pkg.validate_conformance() + r = repr(report) + assert "Native Rules" in r + + +def test_submission_package_to_json_rules(tmp_path): + pkg = pb.SubmissionPackage(datasets={"DM": _clean_dm()}) + report = pkg.validate_conformance() + dest = report.to_json(tmp_path / "r.json") + data = json.loads(dest.read_text()) + assert data["summary"]["engine"] == "native" + assert isinstance(data["issues"], list) + + +def test_submission_package_to_excel_rules(tmp_path): + pytest.importorskip("openpyxl") + import openpyxl + + pkg = pb.SubmissionPackage(datasets={"DM": _clean_dm()}) + report = pkg.validate_conformance() + dest = report.to_excel(tmp_path / "r.xlsx") + wb = openpyxl.load_workbook(dest) + assert "Rules_Report" in wb.sheetnames + assert "Summary" in wb.sheetnames + # Every rule has a row. + n_data_rows = wb["Rules_Report"].max_row - 1 + assert n_data_rows == len(report.rules()) + + +def test_submission_package_rules_accessor(): + pkg = pb.SubmissionPackage(datasets={"DM": _clean_dm()}) + report = pkg.validate_conformance() + rules = report.rules() + assert len(rules) > 0 + assert all(isinstance(r, NativeRuleResult) for r in rules) + + +def test_submission_package_findings_accessor(): + dirty = pl.DataFrame({"STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], "SEX": ["BAD"]}) + pkg = pb.SubmissionPackage(datasets={"DM": dirty}) + report = pkg.validate_conformance() + findings = report.findings() + assert len(findings) > 0 + assert all(isinstance(f, NativeRowFinding) for f in findings) From 4c0b83eb42eef5ce7c6b6ac3a69b0272e605a243 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:20:56 -0400 Subject: [PATCH 40/93] Set conformance tests to validate engine --- tests/test_submission.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_submission.py b/tests/test_submission.py index a86b6fe42..f01e181ff 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -87,7 +87,7 @@ def test_summary_and_repr(): def test_conformance_clean_passes(): study = SubmissionPackage(datasets={"DM": _dm(), "AE": _ae(["S1-001", "S1-002"])}) - report = study.validate_conformance() + report = study.validate_conformance(engine="validate") assert isinstance(report, ConformanceReport) assert report.all_passed() assert report.issues() == [] @@ -102,7 +102,7 @@ def test_conformance_clean_passes(): def test_referential_integrity_flags_orphan_usubjid(): study = SubmissionPackage(datasets={"DM": _dm(), "AE": _ae(["S1-001", "S1-999"])}) - report = study.validate_conformance() + report = study.validate_conformance(engine="validate") assert not report.all_passed() issues = report.issues() assert any(i["dataset"] == "AE" for i in issues) @@ -117,7 +117,7 @@ def test_referential_integrity_flags_orphan_usubjid(): def test_no_dm_means_no_referential_check(): # Without DM there is no reference set, so no referential check is added. study = SubmissionPackage(datasets={"AE": _ae(["S1-001", "S1-002"])}) - report = study.validate_conformance() + report = study.validate_conformance(engine="validate") ae = report["AE"] assert not any(s.brief and "exist in DM" in s.brief for s in ae.validation_info) @@ -129,7 +129,7 @@ def test_polars_datasets_supported(): "AE": pl.from_pandas(_ae(["S1-001", "S1-999"])), } ) - report = study.validate_conformance() + report = study.validate_conformance(engine="validate") assert not report.all_passed() @@ -155,7 +155,7 @@ def _suppae(idvarvals): def test_supp_idvar_resolution_pass(): ae = _ae(["S1-001"]) # AESEQ == 1 study = SubmissionPackage(datasets={"DM": _dm(), "AE": ae, "SUPPAE": _suppae([1])}) - report = study.validate_conformance() + report = study.validate_conformance(engine="validate") supp = report["SUPPAE"] idvar_steps = [s for s in supp.validation_info if s.brief and "IDVAR" in s.brief] assert len(idvar_steps) == 1 @@ -165,7 +165,7 @@ def test_supp_idvar_resolution_pass(): def test_supp_idvar_resolution_flags_dangling_link(): ae = _ae(["S1-001"]) # AESEQ == 1 only study = SubmissionPackage(datasets={"DM": _dm(), "AE": ae, "SUPPAE": _suppae([99])}) - report = study.validate_conformance() + report = study.validate_conformance(engine="validate") supp = report["SUPPAE"] idvar_steps = [s for s in supp.validation_info if s.brief and "IDVAR" in s.brief] assert idvar_steps[0].n_failed == 1 @@ -175,7 +175,7 @@ def test_supp_rdomain_must_be_present(): supp = _suppae([1, 1]) # two rows supp["RDOMAIN"] = "ZZ" # not a present domain study = SubmissionPackage(datasets={"DM": _dm(), "AE": _ae(["S1-001"]), "SUPPAE": supp}) - report = study.validate_conformance() + report = study.validate_conformance(engine="validate") supp_v = report["SUPPAE"] rdom_steps = [s for s in supp_v.validation_info if s.brief and "RDOMAIN" in s.brief] assert rdom_steps[0].n_failed == 2 @@ -210,7 +210,7 @@ def test_adam_adsl_traces_to_dm(): standard="adamig", standard_version="1.1", ) - report = study.validate_conformance() + report = study.validate_conformance(engine="validate") adsl = report["ADSL"] trace = [s for s in adsl.validation_info if s.brief and "trace to DM" in s.brief] assert trace[0].n_failed == 1 @@ -232,7 +232,7 @@ def test_adam_dataset_traces_to_adsl(): standard="adamig", standard_version="1.1", ) - report = study.validate_conformance() + report = study.validate_conformance(engine="validate") adae_v = report["ADAE"] trace = [s for s in adae_v.validation_info if s.brief and "trace to ADSL" in s.brief] assert trace[0].n_failed == 1 @@ -243,7 +243,7 @@ def test_adam_dataset_traces_to_adsl(): def test_cross_dataset_can_be_disabled(): study = SubmissionPackage(datasets={"DM": _dm(), "AE": _ae(["S1-001", "S1-999"])}) - report = study.validate_conformance(cross_dataset=False) + report = study.validate_conformance(cross_dataset=False, engine="validate") ae = report["AE"] assert not any(s.brief and "exist in DM" in s.brief for s in ae.validation_info) @@ -253,7 +253,7 @@ def test_cross_dataset_can_be_disabled(): def test_report_issues_and_html(): study = SubmissionPackage(datasets={"DM": _dm(), "AE": _ae(["S1-001", "S1-999"])}) - report = study.validate_conformance() + report = study.validate_conformance(engine="validate") issues = report.issues() assert all({"dataset", "step", "assertion", "n_failed"} <= set(i) for i in issues) html = report._repr_html_() @@ -266,7 +266,7 @@ def test_report_issues_and_html(): def test_report_agency_recorded(): study = SubmissionPackage(datasets={"DM": _dm()}) - report = study.validate_conformance(agency="FDA") + report = study.validate_conformance(agency="FDA", engine="validate") assert report.agency == "FDA" assert "FDA" in report._repr_html_() @@ -288,7 +288,7 @@ def test_from_folder_xpt_and_define_autodetect(): assert study.define is not None assert study.metadata is not None - report = study.validate_conformance() + report = study.validate_conformance(engine="validate") assert report.all_passed() From 9668238a737e0632ca756dae2613a3ae57f354dd Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 17:41:15 -0400 Subject: [PATCH 41/93] Add generators for CT and rule catalogs --- scripts/generate_ct_bundle.py | 351 +++++++++++++++++++++++++++++++ scripts/generate_rule_catalog.py | 272 ++++++++++++++++++++++++ 2 files changed, 623 insertions(+) create mode 100755 scripts/generate_ct_bundle.py create mode 100755 scripts/generate_rule_catalog.py diff --git a/scripts/generate_ct_bundle.py b/scripts/generate_ct_bundle.py new file mode 100755 index 000000000..e61e8db1d --- /dev/null +++ b/scripts/generate_ct_bundle.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +"""Generate a pointblank bundled CT (Controlled Terminology) package from NCI EVS. + +Usage +----- + # Latest CDISC SDTM CT package: + python scripts/generate_ct_bundle.py + + # Specific package by NCI date identifier: + python scripts/generate_ct_bundle.py --package SDTM_CT_2024-09-27 + + # List packages available from NCI EVS: + python scripts/generate_ct_bundle.py --list + +No API key required. NCI EVS is a public API. + +NCI EVS API +----------- +Base URL: https://api-evsrest.nci.nih.gov/api/v1 + +Endpoints used: + + GET /concept/ncit/search?terminology=ncit&q=CDISC+SDTM+Terminology&type=match + Locate the CDISC SDTM CT root concept (C66830 for SDTM CT). + + GET /concept/ncit/{code}/descendants + All descendant codelist concepts. + + GET /concept/ncit/{code}?include=full + Full concept detail including synonyms and properties. + +Alternative source (flat file download) +--------------------------------------- +NCI publishes complete CDISC CT packages as flat text files: + + https://evs.nci.nih.gov/ftp1/CDISC/SDTM/SDTM%20Terminology.txt + +This script uses the flat file approach by default (simpler, more complete). +Pass `--api` to use the REST API instead (slower, useful for automated pipelines). + +Output +------ +Writes to `pointblank/data/conformance/ct/{package}.json` with format:: + + { + "package": "sdtm-ct-2024-09-27", + "source": "NCI EVS CDISC Controlled Terminology 2024-09-27", + "codelists": { + "SEX": ["M", "F", "U", "UNDIFFERENTIATED"], + "NY": ["N", "Y"], + ... + } + } + +Codelist values are stored as sorted lists (sets in memory, lists in JSON for +deterministic diffs). Only submission values (the `Submission Value` synonym +type in NCI EVS) are included. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +_NCI_BASE = "https://api-evsrest.nci.nih.gov/api/v1" +_NCI_FTP_BASE = "https://evs.nci.nih.gov/ftp1/CDISC/SDTM" + +# NCI concept codes for CDISC CT root codelists (stable identifiers). +# These are used when fetching via the REST API. +_SDTM_CT_ROOT = "C66830" # SDTM Terminology (top-level) + +_OUT_DIR = Path(__file__).parent.parent / "pointblank" / "data" / "conformance" / "ct" + +# Only include codelists referenced by the native engine's rule catalog. +# Set to None to include all codelists (produces a larger bundle). +# This list is kept in sync with the operations in the rule catalog manually; +# run with --all to include every codelist. +_DEFAULT_CODELISTS = { + "SEX", + "NY", + "ETHNIC", + "RACE", + "COUNTRY", + "AEOUT", + "AESEV", + "AEREL", + "AETOXGR", + "AGEU", + "UNIT", + "NCOMPLT", + "VSRESU", + "LBSTRESU", + "EGSTRESU", +} + + +# ── HTTP helpers ────────────────────────────────────────────────────────────── + + +def _get_json(url: str, retries: int = 3) -> dict | list: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + for attempt in range(retries): + try: + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + if exc.code == 429: + wait = 2 ** (attempt + 1) + print(f" rate-limited, waiting {wait}s…", file=sys.stderr) + time.sleep(wait) + else: + raise + raise RuntimeError(f"Failed after {retries} attempts: {url}") + + +def _get_text(url: str) -> str: + req = urllib.request.Request(url, headers={"Accept": "text/plain"}) + with urllib.request.urlopen(req, timeout=120) as resp: + return resp.read().decode("utf-8", errors="replace") + + +# ── List available packages ─────────────────────────────────────────────────── + + +def _list_packages_from_ftp() -> list[str]: + """Parse directory listing from NCI FTP to find available SDTM CT packages.""" + try: + html = _get_text(_NCI_FTP_BASE + "/") + except Exception: + # Fallback: known packages + return [ + "SDTM_CT_2024-09-27", + "SDTM_CT_2024-06-28", + "SDTM_CT_2024-03-29", + ] + # Extract links to .txt files + packages = re.findall(r'SDTM[^"]*Terminology[^"]*\.txt', html, re.IGNORECASE) + return sorted(set(packages)) + + +# ── Flat-file parser ────────────────────────────────────────────────────────── + + +def _parse_flat_file( + content: str, include_all: bool, filter_codelists: set[str] | None +) -> dict[str, list[str]]: + """Parse NCI EVS CDISC CT flat file (tab-separated) into codelist → submission values. + + The flat file has columns (tab-separated): + Code Codelist Code Codelist Extensible (Yes/No) Codelist Name + CDISC Submission Value CDISC Synonym(s) CDISC Definition NCI Preferred Term + + Rows where `Codelist Code` is blank are codelist headers (the codelist itself); + rows where it is populated are terms within a codelist. + """ + codelists: dict[str, list[str]] = {} + current_codelist: str | None = None + + for line in content.splitlines(): + if not line.strip() or line.startswith("Code\t"): + continue # skip header row and blank lines + + parts = line.split("\t") + if len(parts) < 5: + continue + + codelist_code = parts[1].strip() # blank for codelist header rows + codelist_name = parts[3].strip() + submission_value = parts[4].strip() + + if not codelist_code: + # This is a codelist header row; the submission value IS the codelist name/code. + current_codelist = submission_value or codelist_name + if current_codelist and ( + include_all + or filter_codelists is None + or current_codelist.upper() in (filter_codelists or set()) + ): + codelists.setdefault(current_codelist.upper(), []) + else: + # This is a term row; add the submission value to the current codelist. + if current_codelist and current_codelist.upper() in codelists and submission_value: + codelists[current_codelist.upper()].append(submission_value) + + # Sort terms for deterministic output. + return {name: sorted(terms) for name, terms in codelists.items() if terms} + + +def fetch_via_flat_file( + package_date: str, include_all: bool, filter_codelists: set[str] | None +) -> dict[str, list[str]]: + """Download and parse the NCI EVS CDISC SDTM CT flat file for a given date.""" + # NCI FTP URL format: /ftp1/CDISC/SDTM/SDTM%20Terminology.txt (latest) + # or versioned: /ftp1/CDISC/SDTM/SDTM_CT_2024-09-27/SDTM_CT_2024-09-27.txt + encoded_date = package_date.replace(" ", "%20") + versioned_url = f"{_NCI_FTP_BASE}/{encoded_date}/{package_date}.txt" + latest_url = f"{_NCI_FTP_BASE}/SDTM%20Terminology.txt" + + for url in (versioned_url, latest_url): + try: + print(f"Downloading {url} …") + content = _get_text(url) + print(f" {len(content):,} bytes received.") + return _parse_flat_file(content, include_all, filter_codelists) + except Exception as exc: + print(f" Failed ({exc}), trying next URL…", file=sys.stderr) + + raise RuntimeError( + f"Could not download CT flat file for package '{package_date}'. " + "Check the package name with --list or supply a direct URL." + ) + + +# ── REST API fetcher ────────────────────────────────────────────────────────── + + +def _fetch_codelist_via_api(code: str) -> tuple[str, list[str]]: + """Fetch a single codelist by NCI concept code via the EVS REST API. + + Returns (codelist_name, [submission_value, ...]). + """ + url = f"{_NCI_BASE}/concept/ncit/{code}?include=full" + concept = _get_json(url) + if isinstance(concept, list): + concept = concept[0] if concept else {} + + # Extract submission values from synonyms where type == "CDISC Submission Value". + submission_values: list[str] = [] + for syn in concept.get("synonyms", []): + if syn.get("type") == "CDISC Submission Value": + val = syn.get("name", "").strip() + if val: + submission_values.append(val) + + # The codelist name is the concept's preferred name or the first CDISC synonym. + name = concept.get("name", code) + return name, sorted(submission_values) + + +def fetch_via_api(include_all: bool, filter_codelists: set[str] | None) -> dict[str, list[str]]: + """Fetch codelists via the NCI EVS REST API. Slower but API-friendly.""" + print(f"Fetching SDTM CT root concept ({_SDTM_CT_ROOT}) descendants…") + url = f"{_NCI_BASE}/concept/ncit/{_SDTM_CT_ROOT}/descendants?include=minimal" + descendants = _get_json(url) + if not isinstance(descendants, list): + descendants = descendants.get("concepts", []) + + print(f" {len(descendants)} descendant concepts found.") + codelists: dict[str, list[str]] = {} + + for desc in descendants: + code = desc.get("code", "") + name = desc.get("name", code) + short_name = name.split("(")[0].strip().upper() + + if not include_all and filter_codelists and short_name not in filter_codelists: + continue + + try: + cl_name, terms = _fetch_codelist_via_api(code) + if terms: + codelists[cl_name.upper()] = terms + time.sleep(0.05) + except Exception as exc: + print(f" WARNING: could not fetch {code} ({name}): {exc}", file=sys.stderr) + + return codelists + + +# ── Main ────────────────────────────────────────────────────────────────────── + + +def generate( + package: str, + include_all: bool, + use_api: bool, +) -> Path: + filter_codelists = None if include_all else _DEFAULT_CODELISTS + + if use_api: + codelists = fetch_via_api(include_all, filter_codelists) + else: + codelists = fetch_via_flat_file(package, include_all, filter_codelists) + + # Derive a slug: "SDTM_CT_2024-09-27" → "sdtm-ct-2024-09-27" + slug = package.lower().replace("_", "-") + # Extract date portion for the source string. + date_match = re.search(r"\d{4}-\d{2}-\d{2}", package) + date_str = date_match.group() if date_match else package + + bundle: dict = { + "package": slug, + "source": f"NCI EVS CDISC Controlled Terminology {date_str}", + "codelists": codelists, + } + + out_path = _OUT_DIR / f"{slug}.json" + _OUT_DIR.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"Wrote {len(codelists)} codelists → {out_path}") + return out_path + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--package", + default="SDTM_CT_2024-09-27", + help="NCI EVS package identifier (default: SDTM_CT_2024-09-27)", + ) + parser.add_argument( + "--all", + action="store_true", + dest="include_all", + help="Include all codelists (default: only those referenced by the rule catalog)", + ) + parser.add_argument( + "--api", + action="store_true", + dest="use_api", + help="Use NCI EVS REST API instead of flat file download (slower)", + ) + parser.add_argument( + "--list", + action="store_true", + dest="list_packages", + help="List available CT packages from NCI FTP and exit", + ) + args = parser.parse_args() + + if args.list_packages: + pkgs = _list_packages_from_ftp() + print("Available CDISC SDTM CT packages on NCI FTP:") + for p in pkgs: + print(f" {p}") + return + + generate(args.package, args.include_all, args.use_api) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_rule_catalog.py b/scripts/generate_rule_catalog.py new file mode 100755 index 000000000..212bedcb3 --- /dev/null +++ b/scripts/generate_rule_catalog.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Generate a Pointblank native-engine rule catalog from the CDISC Library API. + +Usage +----- + export CDISC_LIBRARY_API_KEY= + python scripts/generate_rule_catalog.py --standard sdtmig --version 3.4 + +The script fetches all rules for the target standard/version, filters to +`executability == "Fully Executable"`, translates them into the Pointblank catalog format, and +writes the result to:: + + pointblank/data/conformance/rules/{standard}-{version}.json + +CDISC Library API +----------------- +Base URL: https://library.cdisc.org/api +Auth header: `api-key: ` + +Endpoints used: + + GET /mdr/rules/{standard}/{version} + Returns `{"links": ..., "rules": [...]}` where each rule has the + structure documented in `_translate_rule` below. + + GET /mdr/rules/{standard}/{version}/{rule_id} + Full rule detail (fetched when the list endpoint returns a stub). + +Obtaining an API key +-------------------- +Request a free key at https://library.cdisc.org/ (registration required). The key is only needed by +Pointblank maintainers whereas end users never need it. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +_BASE_URL = "https://library.cdisc.org/api" +_OUT_DIR = Path(__file__).parent.parent / "pointblank" / "data" / "conformance" / "rules" + +# CDISC Library rule types we handle natively (Phase 1 + Phase 2 scope). +# All other types are included in the catalog but marked appropriately so +# the engine can return STATUS_NOT_SUPPORTED for them. +_SUPPORTED_RULE_TYPES = { + "RECORD_CHECK", + "DATASET_CONTENTS_CHECK", + "DATASET_METADATA_CHECK", + "DOMAIN_PRESENCE_CHECK", + "VARIABLE_METADATA_CHECK", +} + +# Executability values to include. "Partially Executable" rules are included +# per the plan decision (they raise an explicit error when dependencies are absent +# rather than being silently skipped). +_INCLUDE_EXECUTABILITY = {"Fully Executable", "Partially Executable"} + + +# ── HTTP helpers ────────────────────────────────────────────────────────────── + + +def _get(path: str, api_key: str, retries: int = 3) -> dict: + url = f"{_BASE_URL}{path}" + req = urllib.request.Request(url, headers={"api-key": api_key, "Accept": "application/json"}) + for attempt in range(retries): + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + if exc.code == 429: + wait = 2 ** (attempt + 1) + print(f" rate-limited, waiting {wait}s…", file=sys.stderr) + time.sleep(wait) + elif exc.code == 404: + raise FileNotFoundError(f"Not found: {url}") from exc + else: + raise + raise RuntimeError(f"Failed after {retries} attempts: {url}") + + +# ── Translation ─────────────────────────────────────────────────────────────── + + +def _translate_operation(op: dict) -> dict: + """Translate a single CDISC Library operation dict to catalog format. + + CDISC Library operation shape (typical): + { + "id": "codelist_check", + "params": {"codelist": "SEX", "variable": "SEX"}, + "name": "Codelist check for SEX" # optional human label + } + + We pass through the dict unchanged; the engine's operations registry handles recognition by + `id`. + """ + return { + "id": op.get("id", ""), + "params": op.get("params", {}), + } + + +def _translate_condition(cond: Any) -> Any: + """Pass condition trees through unchanged as they already use the catalog format.""" + return cond + + +def _translate_rule(raw: dict) -> dict: + """Translate a CDISC Library rule object to the Pointblank catalog format. + + Expected CDISC Library rule shape + ---------------------------------- + { + "core_id": "SDTMIG.DM.001", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "...", + "authority": "CDISC", + "standards": [{"name": "sdtmig", "version": "3.4"}], + "classes": [{"name": "Special Purpose"}], + "domains": [{"name": "DM"}], + "datasets": [], + "operations": [...], + "conditions": {...}, + "actions": { + "id": "generate_record_error", + "params": {"message": "..."} + } + } + + The `standards`, `classes`, and `domains` fields may be lists of objects or lists of strings + depending on the API version; both are handled. + """ + + def _names(items: list) -> list[str]: + """Extract string names from either [{name: ...}] or ["..."] lists.""" + result = [] + for item in items or []: + if isinstance(item, str): + result.append(item) + elif isinstance(item, dict): + result.append(item.get("name", "")) + return [n for n in result if n] + + return { + "core_id": raw.get("core_id", ""), + "rule_type": raw.get("rule_type", ""), + "executability": raw.get("executability", "Fully Executable"), + "sensitivity": raw.get("sensitivity", "Error"), + "description": raw.get("description", ""), + "authority": raw.get("authority", "CDISC"), + "standards": _names(raw.get("standards", [])), + "classes": _names(raw.get("classes", [])), + "domains": _names(raw.get("domains", [])), + "datasets": raw.get("datasets", []), + "operations": [_translate_operation(o) for o in raw.get("operations", [])], + "conditions": _translate_condition(raw.get("conditions", {})), + "actions": raw.get("actions", {}), + } + + +# ── Fetch ───────────────────────────────────────────────────────────────────── + + +def fetch_rules(standard: str, version: str, api_key: str) -> list[dict]: + """Fetch all rules for *standard*/*version* from the CDISC Library API. + + The list endpoint may return stubs that lack full detail; when a rule's `operations` or + `conditions` are absent we fetch the full rule object. + """ + path = f"/mdr/rules/{standard}/{version}" + print(f"Fetching rule list from {_BASE_URL}{path} …") + data = _get(path, api_key) + raw_rules: list[dict] = data.get("rules", []) + print(f" {len(raw_rules)} rules returned.") + + full_rules: list[dict] = [] + for i, rule in enumerate(raw_rules, 1): + rule_id = rule.get("core_id", f"rule-{i}") + # Fetch full detail when the stub is missing key fields. + if "conditions" not in rule or "operations" not in rule: + try: + detail_path = f"/mdr/rules/{standard}/{version}/{rule_id}" + rule = _get(detail_path, api_key) + time.sleep(0.1) # be polite + except FileNotFoundError: + print(f" WARNING: detail not found for {rule_id}, using stub.", file=sys.stderr) + full_rules.append(rule) + if i % 50 == 0: + print(f" fetched {i}/{len(raw_rules)}…") + + return full_rules + + +# ── Main ────────────────────────────────────────────────────────────────────── + + +def generate(standard: str, version: str, api_key: str, include_all_executability: bool) -> Path: + raw_rules = fetch_rules(standard, version, api_key) + + translated: list[dict] = [] + skipped = 0 + for raw in raw_rules: + executability = raw.get("executability", "Fully Executable") + if not include_all_executability and executability not in _INCLUDE_EXECUTABILITY: + skipped += 1 + continue + translated.append(_translate_rule(raw)) + + print(f"Translated {len(translated)} rules ({skipped} skipped by executability filter).") + + # Checksum over the sorted rule list for staleness detection. + rules_bytes = json.dumps(translated, sort_keys=True, ensure_ascii=False).encode() + checksum = hashlib.sha256(rules_bytes).hexdigest()[:16] + + catalog: dict = { + "standard": standard.lower(), + "version": version, + "generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "source": f"CDISC Library API — {standard.upper()} {version}", + "checksum": checksum, + "rules": translated, + } + + slug = f"{standard.lower()}-{version.replace('.', '-')}" + out_path = _OUT_DIR / f"{slug}.json" + _OUT_DIR.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"Wrote {len(translated)} rules → {out_path}") + return out_path + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--standard", default="sdtmig", help="CDISC standard slug (default: sdtmig)" + ) + parser.add_argument("--version", default="3.4", help="Standard version (default: 3.4)") + parser.add_argument( + "--all-executability", + action="store_true", + help="Include rules of all executability levels (default: Fully/Partially Executable only)", + ) + parser.add_argument("--api-key", help="CDISC Library API key (default: $CDISC_LIBRARY_API_KEY)") + args = parser.parse_args() + + api_key = args.api_key or os.environ.get("CDISC_LIBRARY_API_KEY", "") + if not api_key: + print( + "ERROR: CDISC Library API key required. Set $CDISC_LIBRARY_API_KEY or pass --api-key.", + file=sys.stderr, + ) + sys.exit(1) + + generate(args.standard, args.version, api_key, args.all_executability) + + +if __name__ == "__main__": + main() From 470b194498658da64fed827f4f2fe4eaf4f5a2d1 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 18:04:50 -0400 Subject: [PATCH 42/93] Add SDTM variable metadata check rules (031-050) --- .../data/conformance/rules/sdtmig-3-4.json | 1935 +++++++++++++++-- 1 file changed, 1758 insertions(+), 177 deletions(-) diff --git a/pointblank/data/conformance/rules/sdtmig-3-4.json b/pointblank/data/conformance/rules/sdtmig-3-4.json index 61300bc15..6215920e3 100644 --- a/pointblank/data/conformance/rules/sdtmig-3-4.json +++ b/pointblank/data/conformance/rules/sdtmig-3-4.json @@ -1,9 +1,9 @@ { "standard": "sdtmig", "version": "3.4", - "generated": "2025-07-13T00:00:00Z", + "generated": "2026-07-13T21:56:24Z", "source": "CDISC SDTM Implementation Guide 3.4, hand-curated from public specification", - "checksum": "sdtmig-3-4-v1", + "checksum": "4d210bbc1f1ba8e4", "rules": [ { "core_id": "SDTM-001", @@ -12,7 +12,9 @@ "sensitivity": "Error", "description": "DM (Demographics) domain is required in every SDTM submission.", "authority": "CDISC", - "standards": ["sdtmig"], + "standards": [ + "sdtmig" + ], "classes": [], "domains": [], "datasets": [], @@ -21,7 +23,9 @@ "actions": { "id": "domain_presence", "params": { - "required_domains": ["DM"], + "required_domains": [ + "DM" + ], "prohibited_domains": [], "message": "DM domain must be present in every SDTM submission." } @@ -34,19 +38,29 @@ "sensitivity": "Error", "description": "STUDYID must not be null in any domain.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["All"], + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], "domains": [], "datasets": [], "operations": [], "conditions": { "all": [ - {"name": "STUDYID", "operator": "is_null", "value": null} + { + "name": "STUDYID", + "operator": "is_null", + "value": null + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "STUDYID must not be null."} + "params": { + "message": "STUDYID must not be null." + } } }, { @@ -56,19 +70,29 @@ "sensitivity": "Error", "description": "DOMAIN must not be null in any SDTM dataset.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["All"], + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], "domains": [], "datasets": [], "operations": [], "conditions": { "all": [ - {"name": "DOMAIN", "operator": "is_null", "value": null} + { + "name": "DOMAIN", + "operator": "is_null", + "value": null + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "DOMAIN must not be null."} + "params": { + "message": "DOMAIN must not be null." + } } }, { @@ -78,19 +102,29 @@ "sensitivity": "Error", "description": "USUBJID must not be null in any SDTM dataset.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["All"], + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], "domains": [], "datasets": [], "operations": [], "conditions": { "all": [ - {"name": "USUBJID", "operator": "is_null", "value": null} + { + "name": "USUBJID", + "operator": "is_null", + "value": null + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "USUBJID must not be null."} + "params": { + "message": "USUBJID must not be null." + } } }, { @@ -100,21 +134,36 @@ "sensitivity": "Error", "description": "STUDYID must be consistent (same value) across all records in a dataset.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["All"], + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], "domains": [], "datasets": [], "operations": [ - {"operator": "consistency_check", "params": {"column": "STUDYID"}} + { + "operator": "consistency_check", + "params": { + "column": "STUDYID" + } + } ], "conditions": { "all": [ - {"name": "_pb_STUDYID_consistent", "operator": "equal_to", "value": false} + { + "name": "_pb_STUDYID_consistent", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_dataset_error", - "params": {"message": "STUDYID must be the same value across all records."} + "params": { + "message": "STUDYID must be the same value across all records." + } } }, { @@ -124,21 +173,36 @@ "sensitivity": "Error", "description": "DOMAIN must be consistent (same value) across all records in a dataset.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["All"], + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], "domains": [], "datasets": [], "operations": [ - {"operator": "consistency_check", "params": {"column": "DOMAIN"}} + { + "operator": "consistency_check", + "params": { + "column": "DOMAIN" + } + } ], "conditions": { "all": [ - {"name": "_pb_DOMAIN_consistent", "operator": "equal_to", "value": false} + { + "name": "_pb_DOMAIN_consistent", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_dataset_error", - "params": {"message": "DOMAIN must be the same value across all records."} + "params": { + "message": "DOMAIN must be the same value across all records." + } } }, { @@ -148,22 +212,44 @@ "sensitivity": "Error", "description": "SEX in DM must use values from the CDISC controlled terminology codelist SEX.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Special-Purpose"], - "domains": ["DM"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], "datasets": [], "operations": [ - {"operator": "codelist_check", "params": {"column": "SEX", "codelist": "SEX"}} + { + "operator": "codelist_check", + "params": { + "column": "SEX", + "codelist": "SEX" + } + } ], "conditions": { "all": [ - {"name": "SEX", "operator": "is_not_null", "value": null}, - {"name": "_pb_SEX_valid", "operator": "equal_to", "value": false} + { + "name": "SEX", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_SEX_valid", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "SEX value is not in the SEX codelist."} + "params": { + "message": "SEX value is not in the SEX codelist." + } } }, { @@ -173,22 +259,44 @@ "sensitivity": "Error", "description": "RACE in DM must use values from the CDISC controlled terminology codelist RACE.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Special-Purpose"], - "domains": ["DM"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], "datasets": [], "operations": [ - {"operator": "codelist_check", "params": {"column": "RACE", "codelist": "RACE"}} + { + "operator": "codelist_check", + "params": { + "column": "RACE", + "codelist": "RACE" + } + } ], "conditions": { "all": [ - {"name": "RACE", "operator": "is_not_null", "value": null}, - {"name": "_pb_RACE_valid", "operator": "equal_to", "value": false} + { + "name": "RACE", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_RACE_valid", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "RACE value is not in the RACE codelist."} + "params": { + "message": "RACE value is not in the RACE codelist." + } } }, { @@ -198,22 +306,44 @@ "sensitivity": "Error", "description": "COUNTRY in DM must use ISO 3166 alpha-3 country codes (CDISC COUNTRY codelist).", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Special-Purpose"], - "domains": ["DM"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], "datasets": [], "operations": [ - {"operator": "codelist_check", "params": {"column": "COUNTRY", "codelist": "COUNTRY"}} + { + "operator": "codelist_check", + "params": { + "column": "COUNTRY", + "codelist": "COUNTRY" + } + } ], "conditions": { "all": [ - {"name": "COUNTRY", "operator": "is_not_null", "value": null}, - {"name": "_pb_COUNTRY_valid", "operator": "equal_to", "value": false} + { + "name": "COUNTRY", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_COUNTRY_valid", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "COUNTRY value is not in the COUNTRY codelist (ISO 3166 alpha-3)."} + "params": { + "message": "COUNTRY value is not in the COUNTRY codelist (ISO 3166 alpha-3)." + } } }, { @@ -223,22 +353,43 @@ "sensitivity": "Error", "description": "DMDTC in DM must be in ISO 8601 extended datetime format.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Special-Purpose"], - "domains": ["DM"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], "datasets": [], "operations": [ - {"operator": "iso8601_check", "params": {"column": "DMDTC"}} + { + "operator": "iso8601_check", + "params": { + "column": "DMDTC" + } + } ], "conditions": { "all": [ - {"name": "DMDTC", "operator": "is_not_null", "value": null}, - {"name": "_pb_DMDTC_iso8601", "operator": "equal_to", "value": false} + { + "name": "DMDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_DMDTC_iso8601", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "DMDTC does not conform to ISO 8601 extended datetime format."} + "params": { + "message": "DMDTC does not conform to ISO 8601 extended datetime format." + } } }, { @@ -248,22 +399,43 @@ "sensitivity": "Error", "description": "RFSTDTC in DM must be in ISO 8601 extended datetime format when present.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Special-Purpose"], - "domains": ["DM"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], "datasets": [], "operations": [ - {"operator": "iso8601_check", "params": {"column": "RFSTDTC"}} + { + "operator": "iso8601_check", + "params": { + "column": "RFSTDTC" + } + } ], "conditions": { "all": [ - {"name": "RFSTDTC", "operator": "is_not_null", "value": null}, - {"name": "_pb_RFSTDTC_iso8601", "operator": "equal_to", "value": false} + { + "name": "RFSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_RFSTDTC_iso8601", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "RFSTDTC does not conform to ISO 8601 extended datetime format."} + "params": { + "message": "RFSTDTC does not conform to ISO 8601 extended datetime format." + } } }, { @@ -273,22 +445,43 @@ "sensitivity": "Error", "description": "RFENDTC in DM must be in ISO 8601 extended datetime format when present.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Special-Purpose"], - "domains": ["DM"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], "datasets": [], "operations": [ - {"operator": "iso8601_check", "params": {"column": "RFENDTC"}} + { + "operator": "iso8601_check", + "params": { + "column": "RFENDTC" + } + } ], "conditions": { "all": [ - {"name": "RFENDTC", "operator": "is_not_null", "value": null}, - {"name": "_pb_RFENDTC_iso8601", "operator": "equal_to", "value": false} + { + "name": "RFENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_RFENDTC_iso8601", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "RFENDTC does not conform to ISO 8601 extended datetime format."} + "params": { + "message": "RFENDTC does not conform to ISO 8601 extended datetime format." + } } }, { @@ -298,22 +491,43 @@ "sensitivity": "Error", "description": "DTHDTC in DM must be in ISO 8601 extended datetime format when present.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Special-Purpose"], - "domains": ["DM"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], "datasets": [], "operations": [ - {"operator": "iso8601_check", "params": {"column": "DTHDTC"}} + { + "operator": "iso8601_check", + "params": { + "column": "DTHDTC" + } + } ], "conditions": { "all": [ - {"name": "DTHDTC", "operator": "is_not_null", "value": null}, - {"name": "_pb_DTHDTC_iso8601", "operator": "equal_to", "value": false} + { + "name": "DTHDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_DTHDTC_iso8601", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "DTHDTC does not conform to ISO 8601 extended datetime format."} + "params": { + "message": "DTHDTC does not conform to ISO 8601 extended datetime format." + } } }, { @@ -323,22 +537,44 @@ "sensitivity": "Error", "description": "DTHFL in DM must use values from the NY codelist (Y or null).", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Special-Purpose"], - "domains": ["DM"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], "datasets": [], "operations": [ - {"operator": "codelist_check", "params": {"column": "DTHFL", "codelist": "NY"}} + { + "operator": "codelist_check", + "params": { + "column": "DTHFL", + "codelist": "NY" + } + } ], "conditions": { "all": [ - {"name": "DTHFL", "operator": "is_not_null", "value": null}, - {"name": "_pb_DTHFL_valid", "operator": "equal_to", "value": false} + { + "name": "DTHFL", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_DTHFL_valid", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "DTHFL value must be 'Y' or null (NY codelist)."} + "params": { + "message": "DTHFL value must be 'Y' or null (NY codelist)." + } } }, { @@ -348,19 +584,31 @@ "sensitivity": "Error", "description": "SUBJID must not be null in DM.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Special-Purpose"], - "domains": ["DM"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], "datasets": [], "operations": [], "conditions": { "all": [ - {"name": "SUBJID", "operator": "is_null", "value": null} + { + "name": "SUBJID", + "operator": "is_null", + "value": null + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "SUBJID must not be null in DM."} + "params": { + "message": "SUBJID must not be null in DM." + } } }, { @@ -370,19 +618,31 @@ "sensitivity": "Error", "description": "AETERM must not be null in AE.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Events"], - "domains": ["AE"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], "datasets": [], "operations": [], "conditions": { "all": [ - {"name": "AETERM", "operator": "is_null", "value": null} + { + "name": "AETERM", + "operator": "is_null", + "value": null + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "AETERM (Reported Term for the Adverse Event) must not be null in AE."} + "params": { + "message": "AETERM (Reported Term for the Adverse Event) must not be null in AE." + } } }, { @@ -392,19 +652,31 @@ "sensitivity": "Error", "description": "AEDECOD must not be null in AE.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Events"], - "domains": ["AE"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], "datasets": [], "operations": [], "conditions": { "all": [ - {"name": "AEDECOD", "operator": "is_null", "value": null} + { + "name": "AEDECOD", + "operator": "is_null", + "value": null + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "AEDECOD (Dictionary-Derived Term) must not be null in AE."} + "params": { + "message": "AEDECOD (Dictionary-Derived Term) must not be null in AE." + } } }, { @@ -414,22 +686,43 @@ "sensitivity": "Error", "description": "AESTDTC in AE must be in ISO 8601 extended datetime format when present.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Events"], - "domains": ["AE"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], "datasets": [], "operations": [ - {"operator": "iso8601_check", "params": {"column": "AESTDTC"}} + { + "operator": "iso8601_check", + "params": { + "column": "AESTDTC" + } + } ], "conditions": { "all": [ - {"name": "AESTDTC", "operator": "is_not_null", "value": null}, - {"name": "_pb_AESTDTC_iso8601", "operator": "equal_to", "value": false} + { + "name": "AESTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AESTDTC_iso8601", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "AESTDTC does not conform to ISO 8601 extended datetime format."} + "params": { + "message": "AESTDTC does not conform to ISO 8601 extended datetime format." + } } }, { @@ -439,22 +732,43 @@ "sensitivity": "Error", "description": "AEENDTC in AE must be in ISO 8601 extended datetime format when present.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Events"], - "domains": ["AE"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], "datasets": [], "operations": [ - {"operator": "iso8601_check", "params": {"column": "AEENDTC"}} + { + "operator": "iso8601_check", + "params": { + "column": "AEENDTC" + } + } ], "conditions": { "all": [ - {"name": "AEENDTC", "operator": "is_not_null", "value": null}, - {"name": "_pb_AEENDTC_iso8601", "operator": "equal_to", "value": false} + { + "name": "AEENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AEENDTC_iso8601", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "AEENDTC does not conform to ISO 8601 extended datetime format."} + "params": { + "message": "AEENDTC does not conform to ISO 8601 extended datetime format." + } } }, { @@ -464,22 +778,44 @@ "sensitivity": "Error", "description": "AESER in AE must use values from the NY codelist when present.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Events"], - "domains": ["AE"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], "datasets": [], "operations": [ - {"operator": "codelist_check", "params": {"column": "AESER", "codelist": "NY"}} + { + "operator": "codelist_check", + "params": { + "column": "AESER", + "codelist": "NY" + } + } ], "conditions": { "all": [ - {"name": "AESER", "operator": "is_not_null", "value": null}, - {"name": "_pb_AESER_valid", "operator": "equal_to", "value": false} + { + "name": "AESER", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AESER_valid", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "AESER value must be in the NY codelist (Y/N)."} + "params": { + "message": "AESER value must be in the NY codelist (Y/N)." + } } }, { @@ -489,22 +825,44 @@ "sensitivity": "Error", "description": "AEOUT in AE must use values from the AEOUT codelist when present.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Events"], - "domains": ["AE"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], "datasets": [], "operations": [ - {"operator": "codelist_check", "params": {"column": "AEOUT", "codelist": "AEOUT"}} + { + "operator": "codelist_check", + "params": { + "column": "AEOUT", + "codelist": "AEOUT" + } + } ], "conditions": { "all": [ - {"name": "AEOUT", "operator": "is_not_null", "value": null}, - {"name": "_pb_AEOUT_valid", "operator": "equal_to", "value": false} + { + "name": "AEOUT", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AEOUT_valid", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "AEOUT value is not in the AEOUT codelist."} + "params": { + "message": "AEOUT value is not in the AEOUT codelist." + } } }, { @@ -514,22 +872,43 @@ "sensitivity": "Error", "description": "LBDTC in LB must be in ISO 8601 extended datetime format when present.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Findings"], - "domains": ["LB"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], "datasets": [], "operations": [ - {"operator": "iso8601_check", "params": {"column": "LBDTC"}} + { + "operator": "iso8601_check", + "params": { + "column": "LBDTC" + } + } ], "conditions": { "all": [ - {"name": "LBDTC", "operator": "is_not_null", "value": null}, - {"name": "_pb_LBDTC_iso8601", "operator": "equal_to", "value": false} + { + "name": "LBDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_LBDTC_iso8601", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "LBDTC does not conform to ISO 8601 extended datetime format."} + "params": { + "message": "LBDTC does not conform to ISO 8601 extended datetime format." + } } }, { @@ -539,19 +918,31 @@ "sensitivity": "Error", "description": "LBTEST must not be null in LB.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Findings"], - "domains": ["LB"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], "datasets": [], "operations": [], "conditions": { "all": [ - {"name": "LBTEST", "operator": "is_null", "value": null} + { + "name": "LBTEST", + "operator": "is_null", + "value": null + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "LBTEST (Lab Test Name) must not be null in LB."} + "params": { + "message": "LBTEST (Lab Test Name) must not be null in LB." + } } }, { @@ -561,19 +952,31 @@ "sensitivity": "Error", "description": "LBTESTCD must not be null in LB.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Findings"], - "domains": ["LB"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], "datasets": [], "operations": [], "conditions": { "all": [ - {"name": "LBTESTCD", "operator": "is_null", "value": null} + { + "name": "LBTESTCD", + "operator": "is_null", + "value": null + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "LBTESTCD (Lab Test Short Name) must not be null in LB."} + "params": { + "message": "LBTESTCD (Lab Test Short Name) must not be null in LB." + } } }, { @@ -583,19 +986,31 @@ "sensitivity": "Error", "description": "VSTEST must not be null in VS.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Findings"], - "domains": ["VS"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], "datasets": [], "operations": [], "conditions": { "all": [ - {"name": "VSTEST", "operator": "is_null", "value": null} + { + "name": "VSTEST", + "operator": "is_null", + "value": null + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "VSTEST (Vital Signs Test Name) must not be null in VS."} + "params": { + "message": "VSTEST (Vital Signs Test Name) must not be null in VS." + } } }, { @@ -605,22 +1020,43 @@ "sensitivity": "Error", "description": "VSDTC in VS must be in ISO 8601 extended datetime format when present.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Findings"], - "domains": ["VS"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], "datasets": [], "operations": [ - {"operator": "iso8601_check", "params": {"column": "VSDTC"}} + { + "operator": "iso8601_check", + "params": { + "column": "VSDTC" + } + } ], "conditions": { "all": [ - {"name": "VSDTC", "operator": "is_not_null", "value": null}, - {"name": "_pb_VSDTC_iso8601", "operator": "equal_to", "value": false} + { + "name": "VSDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_VSDTC_iso8601", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "VSDTC does not conform to ISO 8601 extended datetime format."} + "params": { + "message": "VSDTC does not conform to ISO 8601 extended datetime format." + } } }, { @@ -630,21 +1066,36 @@ "sensitivity": "Warning", "description": "USUBJID must be present in every SDTM domain.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["All"], + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], "domains": [], "datasets": [], "operations": [ - {"operator": "column_presence", "params": {"column": "USUBJID"}} + { + "operator": "column_presence", + "params": { + "column": "USUBJID" + } + } ], "conditions": { "all": [ - {"name": "_pb_USUBJID_present", "operator": "equal_to", "value": false} + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_dataset_error", - "params": {"message": "USUBJID column is required in all SDTM domains."} + "params": { + "message": "USUBJID column is required in all SDTM domains." + } } }, { @@ -654,21 +1105,36 @@ "sensitivity": "Error", "description": "STUDYID must be present in every SDTM domain.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["All"], + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], "domains": [], "datasets": [], "operations": [ - {"operator": "column_presence", "params": {"column": "STUDYID"}} + { + "operator": "column_presence", + "params": { + "column": "STUDYID" + } + } ], "conditions": { "all": [ - {"name": "_pb_STUDYID_present", "operator": "equal_to", "value": false} + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_dataset_error", - "params": {"message": "STUDYID column is required in all SDTM domains."} + "params": { + "message": "STUDYID column is required in all SDTM domains." + } } }, { @@ -678,21 +1144,36 @@ "sensitivity": "Error", "description": "DOMAIN must be present in every SDTM domain.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["All"], + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], "domains": [], "datasets": [], "operations": [ - {"operator": "column_presence", "params": {"column": "DOMAIN"}} + { + "operator": "column_presence", + "params": { + "column": "DOMAIN" + } + } ], "conditions": { "all": [ - {"name": "_pb_DOMAIN_present", "operator": "equal_to", "value": false} + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_dataset_error", - "params": {"message": "DOMAIN column is required in all SDTM domains."} + "params": { + "message": "DOMAIN column is required in all SDTM domains." + } } }, { @@ -702,23 +1183,1123 @@ "sensitivity": "Error", "description": "ETHNIC in DM must use values from the ETHNIC codelist when present.", "authority": "CDISC", - "standards": ["sdtmig"], - "classes": ["Special-Purpose"], - "domains": ["DM"], + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], "datasets": [], "operations": [ - {"operator": "codelist_check", "params": {"column": "ETHNIC", "codelist": "ETHNIC"}} + { + "operator": "codelist_check", + "params": { + "column": "ETHNIC", + "codelist": "ETHNIC" + } + } ], "conditions": { "all": [ - {"name": "ETHNIC", "operator": "is_not_null", "value": null}, - {"name": "_pb_ETHNIC_valid", "operator": "equal_to", "value": false} + { + "name": "ETHNIC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_ETHNIC_valid", + "operator": "equal_to", + "value": false + } ] }, "actions": { "id": "generate_record_error", - "params": {"message": "ETHNIC value is not in the ETHNIC codelist."} + "params": { + "message": "ETHNIC value is not in the ETHNIC codelist." + } + } + }, + { + "core_id": "SDTM-031", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DM domain must contain all required Identifier and Topic variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "SUBJID" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_SUBJID_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "DM domain is missing one or more required Identifier variables (STUDYID, DOMAIN, USUBJID, SUBJID)." + } + } + }, + { + "core_id": "SDTM-032", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DM domain must contain required demographic variables (SEX, RACE, ETHNIC, COUNTRY).", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "SEX", + "RACE", + "ETHNIC", + "COUNTRY" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_SEX_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_RACE_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_ETHNIC_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_COUNTRY_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "DM domain is missing one or more required demographic variables (SEX, RACE, ETHNIC, COUNTRY)." + } + } + }, + { + "core_id": "SDTM-033", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DM domain must contain RFSTDTC and RFENDTC (reference start/end dates).", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "RFSTDTC", + "RFENDTC" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_RFSTDTC_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_RFENDTC_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "DM domain must include RFSTDTC (Subject Reference Start Date/Time) and RFENDTC (Subject Reference End Date/Time)." + } + } + }, + { + "core_id": "SDTM-034", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DM domain must contain SITEID, AGE, and AGEU.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "SITEID", + "AGE", + "AGEU" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_SITEID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AGE_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AGEU_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "DM domain must include SITEID, AGE, and AGEU." + } + } + }, + { + "core_id": "SDTM-035", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DM domain must contain treatment arm variables (ARMCD, ARM, ACTARMCD, ACTARM).", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "ARMCD", + "ARM", + "ACTARMCD", + "ACTARM" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_ARMCD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_ARM_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_ACTARMCD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_ACTARM_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "DM domain must include treatment arm variables: ARMCD, ARM, ACTARMCD, ACTARM." + } + } + }, + { + "core_id": "SDTM-036", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AE domain must contain required Identifier and sequence variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "AESEQ" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AESEQ_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "AE domain is missing required variables (STUDYID, DOMAIN, USUBJID, AESEQ)." + } + } + }, + { + "core_id": "SDTM-037", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AE domain must contain the AE term and dictionary-derived variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "AETERM", + "AEDECOD", + "AEBODSYS" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_AETERM_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AEDECOD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AEBODSYS_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "AE domain must include AETERM, AEDECOD (MedDRA preferred term), and AEBODSYS (body system)." + } + } + }, + { + "core_id": "SDTM-038", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AE domain must contain severity, seriousness, and outcome variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "AESEV", + "AESER", + "AEOUT" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_AESEV_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AESER_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AEOUT_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "AE domain must include AESEV (severity), AESER (serious flag), and AEOUT (outcome)." + } + } + }, + { + "core_id": "SDTM-039", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VS domain must contain required Identifier and test variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "VSSEQ", + "VSTESTCD", + "VSTEST" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_VSSEQ_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_VSTESTCD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_VSTEST_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VS domain is missing required variables (STUDYID, DOMAIN, USUBJID, VSSEQ, VSTESTCD, VSTEST)." + } + } + }, + { + "core_id": "SDTM-040", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VS domain must contain result variables (VSORRES, VSORRESU, VSSTRESC, VSSTRESN, VSSTRESU).", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "VSORRES", + "VSORRESU", + "VSSTRESC", + "VSSTRESN", + "VSSTRESU" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_VSORRES_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_VSORRESU_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_VSSTRESC_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_VSSTRESN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_VSSTRESU_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VS domain must include original result (VSORRES/VSORRESU) and standardized result (VSSTRESC/VSSTRESN/VSSTRESU) variables." + } + } + }, + { + "core_id": "SDTM-041", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LB domain must contain required Identifier and test variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "LBSEQ", + "LBTESTCD", + "LBTEST" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_LBSEQ_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_LBTESTCD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_LBTEST_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LB domain is missing required variables (STUDYID, DOMAIN, USUBJID, LBSEQ, LBTESTCD, LBTEST)." + } + } + }, + { + "core_id": "SDTM-042", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LB domain must contain result variables (LBORRES, LBORRESU, LBSTRESC, LBSTRESN, LBSTRESU).", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "LBORRES", + "LBORRESU", + "LBSTRESC", + "LBSTRESN", + "LBSTRESU" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_LBORRES_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_LBORRESU_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_LBSTRESC_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_LBSTRESN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_LBSTRESU_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LB domain must include result variables (LBORRES/LBORRESU and LBSTRESC/LBSTRESN/LBSTRESU)." + } + } + }, + { + "core_id": "SDTM-043", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EG domain must contain required Identifier and test variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "EGSEQ", + "EGTESTCD", + "EGTEST" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_EGSEQ_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_EGTESTCD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_EGTEST_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EG domain is missing required variables (STUDYID, DOMAIN, USUBJID, EGSEQ, EGTESTCD, EGTEST)." + } + } + }, + { + "core_id": "SDTM-044", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Warning", + "description": "In any domain, STUDYID must appear before DOMAIN, which must appear before USUBJID.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], + "domains": [], + "datasets": [], + "operations": [ + { + "operator": "valid_variable_order", + "params": { + "expected_order": [ + "STUDYID", + "DOMAIN", + "USUBJID" + ] + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_variable_order_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "Identifier variables must appear in order: STUDYID, DOMAIN, USUBJID." + } + } + }, + { + "core_id": "SDTM-045", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Warning", + "description": "In findings domains (VS, LB, EG), the sequence variable (xSEQ) must appear after USUBJID.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "valid_variable_order", + "params": { + "expected_order": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "VSSEQ" + ] + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_variable_order_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VS: VSSEQ must appear after USUBJID in the column order." + } + } + }, + { + "core_id": "SDTM-046", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "In DM, AGE must be a numeric variable.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "AGE", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AGE_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "DM.AGE must be a numeric variable." + } + } + }, + { + "core_id": "SDTM-047", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "In VS, VSSTRESN must be a numeric variable.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VSSTRESN", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VSSTRESN_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VS.VSSTRESN must be a numeric variable." + } + } + }, + { + "core_id": "SDTM-048", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "In LB, LBSTRESN must be a numeric variable.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBSTRESN", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSTRESN_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LB.LBSTRESN must be a numeric variable." + } + } + }, + { + "core_id": "SDTM-049", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "Variable labels in the submission must match the labels specified in Define-XML.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], + "domains": [], + "datasets": [ + "DEFINE" + ], + "operations": [], + "conditions": {}, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "Variable label does not match Define-XML specification." + } + } + }, + { + "core_id": "SDTM-050", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "Variable data types in the submission must match the types declared in Define-XML.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], + "domains": [], + "datasets": [ + "DEFINE" + ], + "operations": [], + "conditions": {}, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "Variable data type does not match Define-XML specification." + } } } ] -} +} \ No newline at end of file From a73e41990d7e0b3b7f2447b30cee0ebb6c0c09a8 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 18:06:05 -0400 Subject: [PATCH 43/93] Add VARIABLE_METADATA_CHECK support and Partially Executable rule handling --- pointblank/metadata/_conformance/engine.py | 34 ++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/pointblank/metadata/_conformance/engine.py b/pointblank/metadata/_conformance/engine.py index d8be259b2..7e8b5b315 100644 --- a/pointblank/metadata/_conformance/engine.py +++ b/pointblank/metadata/_conformance/engine.py @@ -16,6 +16,7 @@ from pointblank.metadata._conformance.result import ( STATUS_ERROR, STATUS_FAIL, + STATUS_NOT_APPLICABLE, STATUS_NOT_SUPPORTED, STATUS_PASS, NativeConformanceResult, @@ -24,12 +25,13 @@ ) from pointblank.metadata._conformance.rule_loader import NativeRule, RuleLoader -# Rule types handled in Phase 1. +# Rule types handled natively _SUPPORTED_TYPES = { "RECORD_CHECK", "DATASET_METADATA_CHECK", "DOMAIN_PRESENCE_CHECK", "DATASET_CONTENTS_CHECK", + "VARIABLE_METADATA_CHECK", } # Maximum row-level findings to collect per rule (avoids blowing up memory on large datasets). @@ -49,7 +51,7 @@ class NativeConformanceEngine: CT package slugs to load (e.g. `["sdtm-ct-2024-09-27"]`). If `None`, the most recent bundled CT package is used automatically. rule_types - Optional list of rule types to evaluate. Defaults to all Phase 1 supported types. + Optional list of rule types to evaluate. Defaults to all supported types. """ def __init__( @@ -112,11 +114,27 @@ def _evaluate_rule( description=rule.description, ) + # Partially Executable rules require extra datasets (e.g. Define XML metadata). + # Return NOT_APPLICABLE instead of failing when those inputs are absent. + if rule.executability == "Partially Executable": + missing_ds = [d for d in rule.datasets if d.upper() not in datasets] + if missing_ds: + return NativeRuleResult( + rule_id=rule.core_id, + rule_type=rule.rule_type, + dataset=", ".join(rule.datasets), + status=STATUS_NOT_APPLICABLE, + sensitivity=rule.sensitivity, + description=rule.description, + message=f"Required input(s) not provided: {', '.join(missing_ds)}", + ) + handler = { "RECORD_CHECK": self._record_check, "DATASET_METADATA_CHECK": self._dataset_metadata_check, "DOMAIN_PRESENCE_CHECK": self._domain_presence_check, "DATASET_CONTENTS_CHECK": self._dataset_contents_check, + "VARIABLE_METADATA_CHECK": self._variable_metadata_check, }[rule.rule_type] try: @@ -231,6 +249,18 @@ def _dataset_metadata_check( n_issues=n_issues, ) + def _variable_metadata_check( + self, rule: NativeRule, datasets: dict[str, nw.DataFrame] + ) -> NativeRuleResult: + """Variable-level metadata check (presence, order, type). + + Delegates to ``_dataset_metadata_check``; semantically distinct from + ``DATASET_METADATA_CHECK`` (which checks dataset-level attributes like sort keys + or record count) but evaluated identically — operations add scalar broadcast + columns that conditions then test. + """ + return self._dataset_metadata_check(rule, datasets) + def _domain_presence_check( self, rule: NativeRule, datasets: dict[str, nw.DataFrame] ) -> NativeRuleResult: From e25dfda7fe41f6e129fd69bfe2ab9f1e8191cd2c Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 18:06:20 -0400 Subject: [PATCH 44/93] Update __init__.py --- pointblank/metadata/_conformance/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pointblank/metadata/_conformance/__init__.py b/pointblank/metadata/_conformance/__init__.py index f0d3eb030..32fa957ea 100644 --- a/pointblank/metadata/_conformance/__init__.py +++ b/pointblank/metadata/_conformance/__init__.py @@ -1,4 +1,4 @@ -"""Native CDISC conformance rule engine (Phase 1). +"""Native CDISC conformance rule engine. This package implements rule-based CDISC conformance validation without any external subprocess, Docker image, or API calls at runtime. Rules are loaded from bundled JSON catalogs; controlled @@ -16,14 +16,19 @@ from __future__ import annotations +from pointblank.metadata._conformance.ct import ControlledTerminology from pointblank.metadata._conformance.engine import NativeConformanceEngine +from pointblank.metadata._conformance.jsonata import ( + JSONataNotSupported, + JSONataSyntaxError, + evaluate_jsonata, +) from pointblank.metadata._conformance.result import ( NativeConformanceResult, NativeRowFinding, NativeRuleResult, ) from pointblank.metadata._conformance.rule_loader import RuleLoader -from pointblank.metadata._conformance.ct import ControlledTerminology __all__ = [ "NativeConformanceEngine", @@ -32,4 +37,7 @@ "NativeRuleResult", "RuleLoader", "ControlledTerminology", + "evaluate_jsonata", + "JSONataNotSupported", + "JSONataSyntaxError", ] From 629a9ccf2e1d56502557595448ccf6afa1c0d233 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 18:08:07 -0400 Subject: [PATCH 45/93] Add native JSONata subset evaluator --- pointblank/metadata/_conformance/jsonata.py | 469 ++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 pointblank/metadata/_conformance/jsonata.py diff --git a/pointblank/metadata/_conformance/jsonata.py b/pointblank/metadata/_conformance/jsonata.py new file mode 100644 index 000000000..ae5bf360d --- /dev/null +++ b/pointblank/metadata/_conformance/jsonata.py @@ -0,0 +1,469 @@ +"""Minimal JSONata expression evaluator for CDISC conformance rules. + +Implements the subset of JSONata used in CDISC Library rule catalogs: + +- Field access: `STUDYID`, `Dataset.Variable` +- Literals: numbers, strings, `true`, `false`, `null` +- Comparison: `=`, `!=`, `<`, `>`, `<=`, `>=` +- Arithmetic: `+`, `-`, `*`, `/` +- Boolean: `and`, `or`, `not(expr)` +- String functions: `$uppercase`, `$lowercase`, `$string`, `$length`, + `$substring`, `$trim` +- Aggregate functions: `$count`, `$exists`, `$distinct` +- Type check: `$type` + +Complex JSONata features (filter expressions, transforms, regex, lambda) raise `JSONataNotSupported` +so the caller can choose an appropriate fallback. + +Typical usage: + + from pointblank.metadata._conformance.jsonata import evaluate_jsonata + + # Evaluate against a row dict + result = evaluate_jsonata("$uppercase(DOMAIN) = DOMAIN", {"DOMAIN": "AE"}) + # -> True + + # Evaluate against a dataset summary dict + result = evaluate_jsonata("$count(USUBJID) > 0", {"USUBJID": ["S1", "S2"]}) + # -> True +""" + +from __future__ import annotations + +import re +from typing import Any + +__all__ = ["evaluate_jsonata", "JSONataNotSupported", "JSONataSyntaxError"] + + +class JSONataNotSupported(Exception): + """Raised for JSONata constructs outside this evaluator's supported subset.""" + + +class JSONataSyntaxError(Exception): + """Raised when the expression cannot be parsed.""" + + +# ── Tokenizer ───────────────────────────────────────────────────────────────── + +_TOK = re.compile( + r"\s*(" + r"\$[a-zA-Z_][a-zA-Z0-9_]*" # $function / $variable + r"|[a-zA-Z_][a-zA-Z0-9_]*" # identifier / keyword + r'|"(?:[^"\\]|\\.)*"' # double-quoted string + r"|'(?:[^'\\]|\\.)*'" # single-quoted string + r"|[0-9]+(?:\.[0-9]+)?" # number (int or float) + r"|<=" # two-char ops first + r"|>=" + r"|!=" + r"|[=<>+\-*/().,\[\]]" # single-char ops and punctuation + r")\s*" +) + + +def _tokenize(expr: str) -> list[str]: + tokens: list[str] = [] + pos = 0 + while pos < len(expr): + m = _TOK.match(expr, pos) + if not m: + break + tok = m.group(1) + if tok: + tokens.append(tok) + pos = m.end() + return tokens + + +# ── Parser ──────────────────────────────────────────────────────────────────── + + +class _Parser: + """Recursive-descent parser for the JSONata subset. + + Precedence (low -> high): + or + and + comparison (=, !=, <, >, <=, >=) + additive (+, -) + multiplicative (*, /) + unary (not, -) + postfix / primary + """ + + def __init__(self, tokens: list[str]) -> None: + self._tok = tokens + self._pos = 0 + + # ── Token stream helpers ───────────────────────────────────────────────── + + def _peek(self) -> str | None: + return self._tok[self._pos] if self._pos < len(self._tok) else None + + def _consume(self) -> str: + t = self._tok[self._pos] + self._pos += 1 + return t + + def _expect(self, value: str) -> str: + t = self._peek() + if t != value: + raise JSONataSyntaxError(f"Expected {value!r}, got {t!r}") + return self._consume() + + # ── Grammar levels ──────────────────────────────────────────────────────── + + def parse(self) -> tuple: + node = self._or() + if self._pos < len(self._tok): + raise JSONataSyntaxError(f"Unexpected token: {self._peek()!r}") + return node + + def _or(self) -> tuple: + left = self._and() + while self._peek() == "or": + self._consume() + right = self._and() + left = ("or", left, right) + return left + + def _and(self) -> tuple: + left = self._comparison() + while self._peek() == "and": + self._consume() + right = self._comparison() + left = ("and", left, right) + return left + + def _comparison(self) -> tuple: + left = self._additive() + op = self._peek() + if op in ("=", "!=", "<", ">", "<=", ">="): + self._consume() + right = self._additive() + return ("cmp", op, left, right) + return left + + def _additive(self) -> tuple: + left = self._multiplicative() + while self._peek() in ("+", "-"): + op = self._consume() + right = self._multiplicative() + left = ("binop", op, left, right) + return left + + def _multiplicative(self) -> tuple: + left = self._unary() + while self._peek() in ("*", "/"): + op = self._consume() + right = self._unary() + left = ("binop", op, left, right) + return left + + def _unary(self) -> tuple: + t = self._peek() + if t == "-": + self._consume() + return ("neg", self._unary()) + if t and t.lower() == "not": + self._consume() + self._expect("(") + inner = self._or() + self._expect(")") + return ("not", inner) + return self._primary() + + def _primary(self) -> tuple: + t = self._peek() + if t is None: + raise JSONataSyntaxError("Unexpected end of expression") + + # Grouped expression + if t == "(": + self._consume() + inner = self._or() + self._expect(")") + return inner + + # String literal + if (t.startswith('"') and t.endswith('"')) or (t.startswith("'") and t.endswith("'")): + self._consume() + return ("lit", t[1:-1].replace('\\"', '"').replace("\\'", "'")) + + # Number literal + if re.match(r"^[0-9]", t): + self._consume() + return ("lit", float(t) if "." in t else int(t)) + + # Boolean / null literals + if t.lower() == "true": + self._consume() + return ("lit", True) + if t.lower() == "false": + self._consume() + return ("lit", False) + if t.lower() == "null": + self._consume() + return ("lit", None) + + # $function call or $variable + if t.startswith("$"): + self._consume() + name = t[1:] # strip leading $ + if self._peek() == "(": + return self._call(name) + return ("var", t) # bare $variable reference + + # Filter expression — not supported + if t == "[": + raise JSONataNotSupported("Filter expressions [...] are not supported") + + # Plain identifier — may be a path (a.b.c) + if re.match(r"^[a-zA-Z_]", t): + self._consume() + path = [t] + while self._peek() == ".": + self._consume() + nxt = self._peek() + if nxt and re.match(r"^[a-zA-Z_$]", nxt): + path.append(self._consume()) + else: + break + return ("path", path) + + raise JSONataSyntaxError(f"Unexpected token: {t!r}") + + def _call(self, name: str) -> tuple: + self._expect("(") + args: list[tuple] = [] + while self._peek() != ")": + args.append(self._or()) + if self._peek() == ",": + self._consume() + self._expect(")") + return ("call", name, args) + + +# ── Evaluator ───────────────────────────────────────────────────────────────── + + +def _eval(node: tuple, ctx: dict[str, Any]) -> Any: + kind = node[0] + + if kind == "lit": + return node[1] + + if kind == "path": + parts: list[str] = node[1] + value = ctx + for part in parts: + if isinstance(value, dict): + value = value.get(part) + else: + value = None + break + return value + + if kind == "var": + return ctx.get(node[1]) + + if kind == "neg": + v = _eval(node[1], ctx) + return -v if isinstance(v, (int, float)) else None + + if kind == "not": + return not _eval(node[1], ctx) + + if kind == "or": + return bool(_eval(node[1], ctx)) or bool(_eval(node[2], ctx)) + + if kind == "and": + return bool(_eval(node[1], ctx)) and bool(_eval(node[2], ctx)) + + if kind == "cmp": + _, op, lhs, rhs = node + left = _eval(lhs, ctx) + right = _eval(rhs, ctx) + # JSONata uses = and != (not == and !==) + if op == "=": + return left == right + if op == "!=": + return left != right + # For ordering operators, None/null is always less than any value + if left is None or right is None: + return False + if op == "<": + return left < right + if op == ">": + return left > right + if op == "<=": + return left <= right + if op == ">=": + return left >= right + return False + + if kind == "binop": + _, op, lhs, rhs = node + left = _eval(lhs, ctx) + right = _eval(rhs, ctx) + if left is None or right is None: + return None + if op == "+": + return left + right + if op == "-": + return left - right + if op == "*": + return left * right + if op == "/": + return left / right if right != 0 else None + return None + + if kind == "call": + _, name, args = node + evaled = [_eval(a, ctx) for a in args] + return _call_function(name, evaled) + + raise JSONataSyntaxError(f"Unknown AST node: {kind!r}") + + +def _call_function(name: str, args: list[Any]) -> Any: + arg0 = args[0] if args else None + + # ── String functions ───────────────────────────────────────────────────── + if name == "uppercase": + return str(arg0).upper() if arg0 is not None else None + if name == "lowercase": + return str(arg0).lower() if arg0 is not None else None + if name == "string": + return str(arg0) if arg0 is not None else "" + if name == "length": + if arg0 is None: + return 0 + if isinstance(arg0, (list, tuple)): + return len(arg0) + return len(str(arg0)) + if name == "trim": + return str(arg0).strip() if arg0 is not None else None + if name == "substring": + # $substring(str, start, length?) + s = str(arg0) if arg0 is not None else "" + start = int(args[1]) if len(args) > 1 and args[1] is not None else 0 + if start < 0: + start = max(0, len(s) + start) + if len(args) > 2 and args[2] is not None: + length = int(args[2]) + return s[start : start + length] + return s[start:] + + # ── Aggregate functions ────────────────────────────────────────────────── + if name == "count": + if arg0 is None: + return 0 + if isinstance(arg0, (list, tuple)): + return len(arg0) + return 1 # scalar → count of 1 + + if name == "exists": + return arg0 is not None + + if name == "distinct": + if arg0 is None: + return [] + if isinstance(arg0, (list, tuple)): + seen: list = [] + for item in arg0: + if item not in seen: + seen.append(item) + return seen + return [arg0] + + if name == "sum": + if isinstance(arg0, (list, tuple)): + return sum(v for v in arg0 if isinstance(v, (int, float))) + return arg0 if isinstance(arg0, (int, float)) else 0 + + if name == "max": + if isinstance(arg0, (list, tuple)): + nums = [v for v in arg0 if isinstance(v, (int, float))] + return max(nums) if nums else None + return arg0 + + if name == "min": + if isinstance(arg0, (list, tuple)): + nums = [v for v in arg0 if isinstance(v, (int, float))] + return min(nums) if nums else None + return arg0 + + if name == "round": + if arg0 is None: + return None + precision = int(args[1]) if len(args) > 1 and args[1] is not None else 0 + return round(float(arg0), precision) + + if name == "floor": + return int(arg0) if arg0 is not None else None + + if name == "ceil": + import math + + return math.ceil(arg0) if arg0 is not None else None + + if name == "abs": + return abs(arg0) if arg0 is not None else None + + if name == "type": + if arg0 is None: + return "null" + if isinstance(arg0, bool): + return "boolean" + if isinstance(arg0, (int, float)): + return "number" + if isinstance(arg0, str): + return "string" + if isinstance(arg0, (list, tuple)): + return "array" + if isinstance(arg0, dict): + return "object" + return "unknown" + + if name == "not": + return not arg0 + + raise JSONataNotSupported( + f"JSONata function ${name}() is not supported by the native evaluator" + ) + + +# ── Public API ──────────────────────────────────────────────────────────────── + + +def evaluate_jsonata(expr: str, context: dict[str, Any]) -> Any: + """Evaluate a JSONata expression against *context*. + + Parameters + ---------- + expr + A JSONata expression string (e.g. `"$uppercase(DOMAIN) = DOMAIN"`). + context + A dict mapping variable names to their values (typically a row dict or a dataset-level + summary dict). + + Returns + ------- + Any + The result of evaluating the expression. For conformance conditions this is typically a + boolean. + + Raises + ------ + JSONataNotSupported + If the expression uses a JSONata feature outside the supported subset. + JSONataSyntaxError + If the expression cannot be parsed. + """ + tokens = _tokenize(expr.strip()) + if not tokens: + return None + parser = _Parser(tokens) + ast = parser.parse() + return _eval(ast, context) From e9931fa5e1f584c3920f9be9afd0e68d0a7e8185 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 18:09:47 -0400 Subject: [PATCH 46/93] Add three conformance operations --- .../metadata/_conformance/operations.py | 89 +++++++++++++++++-- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/pointblank/metadata/_conformance/operations.py b/pointblank/metadata/_conformance/operations.py index 37ca0e43d..8a5e3ece6 100644 --- a/pointblank/metadata/_conformance/operations.py +++ b/pointblank/metadata/_conformance/operations.py @@ -8,11 +8,14 @@ Registered operations --------------------- -codelist_check -- _pb__valid (True = value in codelist or null) -consistency_check -- _pb__consistent (True = value matches dataset mode, or null) -iso8601_check -- _pb__iso8601 (True = valid ISO 8601 partial/full datetime or null) -unique_per_subject -- _pb__unique (True = value is unique within the USUBJID group) -column_presence -- _pb__present (True = column exists in the dataset, scalar broadcast) +codelist_check -- _pb__valid (True = value in codelist or null) +consistency_check -- _pb__consistent (True = value matches dataset mode, or null) +iso8601_check -- _pb__iso8601 (True = valid ISO 8601 partial/full datetime or null) +unique_per_subject -- _pb__unique (True = value is unique within the USUBJID group) +column_presence -- _pb__present (True = column exists in the dataset, scalar broadcast) +has_required_variables -- _pb__present (batch column_presence for a list of columns) +valid_variable_order -- _pb_variable_order_valid (True = columns appear in expected relative order) +variable_type_check -- _pb__type_valid (True = column dtype matches expected category) """ from __future__ import annotations @@ -147,10 +150,86 @@ def _op_column_presence( return df.with_columns(nw.lit(present).alias(result_col)) +def _op_has_required_variables( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], +) -> nw.DataFrame: + """Add `_pb__present` broadcast columns for each variable in `params["variables"]`. + + Equivalent to running `column_presence` for each variable in the list. Conditions can then check + individual variables or combine them with `any`/`all`. + """ + for col in params.get("variables", []): + result_col = f"_pb_{col}_present" + df = df.with_columns(nw.lit(col in df.columns).alias(result_col)) + return df + + +def _op_valid_variable_order( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], +) -> nw.DataFrame: + """Add `_pb_variable_order_valid` broadcast scalar. + + Checks that the variables listed in `params["expected_order"]` appear in the correct relative + order when both are present. Variables absent from the dataset are skipped (presence is a + separate check). Result is `True` if no order violation is found. + """ + expected: list[str] = params.get("expected_order", []) + # Filter to variables that are actually in the dataset and record their positions. + positions = {col: df.columns.index(col) for col in expected if col in df.columns} + ordered_expected = [col for col in expected if col in positions] + valid = True + for i in range(len(ordered_expected) - 1): + a, b = ordered_expected[i], ordered_expected[i + 1] + if positions[a] > positions[b]: + valid = False + break + return df.with_columns(nw.lit(valid).alias("_pb_variable_order_valid")) + + +def _op_variable_type_check( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], +) -> nw.DataFrame: + """Add `_pb__type_valid` broadcast scalar. + + Checks whether the column's dtype category matches `params["expected_type"]`. Accepted values + for `expected_type`: `"character"` (string/object) or `"numeric"` (integer/float). If the + column is absent, result is `True`. + """ + col: str = params["column"] + expected: str = params.get("expected_type", "character").lower() + result_col = f"_pb_{col}_type_valid" + if col not in df.columns: + return df.with_columns(nw.lit(True).alias(result_col)) + dtype = df[col].dtype + from narwhals import dtypes as _dtypes + + is_numeric = isinstance(dtype, _dtypes.NumericType) + is_string = isinstance(dtype, _dtypes.String) + if expected == "numeric": + valid = is_numeric + elif expected == "character": + valid = is_string + else: + valid = True # unknown expected type → don't flag + return df.with_columns(nw.lit(valid).alias(result_col)) + + _REGISTRY: dict[str, Any] = { "codelist_check": _op_codelist_check, "consistency_check": _op_consistency_check, "iso8601_check": _op_iso8601_check, "unique_per_subject": _op_unique_per_subject, "column_presence": _op_column_presence, + "has_required_variables": _op_has_required_variables, + "valid_variable_order": _op_valid_variable_order, + "variable_type_check": _op_variable_type_check, } From f7744699bdb7e044ff51589a42fb06ddf3bdc3ce Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 18:10:10 -0400 Subject: [PATCH 47/93] Add JSONata and operations tests --- tests/test_native_conformance.py | 284 ++++++++++++++++++++++++++++++- 1 file changed, 283 insertions(+), 1 deletion(-) diff --git a/tests/test_native_conformance.py b/tests/test_native_conformance.py index 80fea5968..0149a6490 100644 --- a/tests/test_native_conformance.py +++ b/tests/test_native_conformance.py @@ -33,9 +33,19 @@ def _clean_dm(backend="polars"): "DOMAIN": ["DM", "DM"], "USUBJID": ["S001-001", "S001-002"], "SUBJID": ["001", "002"], + "RFSTDTC": ["2024-01-01", "2024-01-02"], + "RFENDTC": ["2024-06-30", "2024-06-30"], + "SITEID": ["001", "001"], + "AGE": [45.0, 32.0], + "AGEU": ["YEARS", "YEARS"], "SEX": ["M", "F"], "RACE": ["WHITE", "ASIAN"], + "ETHNIC": ["NOT HISPANIC OR LATINO", "NOT HISPANIC OR LATINO"], "COUNTRY": ["USA", "GBR"], + "ARMCD": ["A", "B"], + "ARM": ["Arm A", "Arm B"], + "ACTARMCD": ["A", "B"], + "ACTARM": ["Arm A", "Arm B"], "DMDTC": ["2024-01-01", "2024-01-02"], } if backend == "pandas": @@ -335,7 +345,7 @@ def test_engine_clean_dm_zero_issues(clean_result): def test_engine_rule_count(clean_result): - assert len(clean_result.rule_results) == 30 + assert len(clean_result.rule_results) == 50 def test_engine_result_types(clean_result): @@ -544,3 +554,275 @@ def test_submission_package_findings_accessor(): findings = report.findings() assert len(findings) > 0 assert all(isinstance(f, NativeRowFinding) for f in findings) + + +# ── Phase 2: JSONata evaluator ──────────────────────────────────────────────── + +from pointblank.metadata._conformance.jsonata import ( + evaluate_jsonata, + JSONataNotSupported, + JSONataSyntaxError, +) + + +def test_jsonata_literals(): + assert evaluate_jsonata("true", {}) is True + assert evaluate_jsonata("false", {}) is False + assert evaluate_jsonata("null", {}) is None + assert evaluate_jsonata("42", {}) == 42 + assert evaluate_jsonata("3.14", {}) == 3.14 + assert evaluate_jsonata('"hello"', {}) == "hello" + + +def test_jsonata_field_access(): + assert evaluate_jsonata("DOMAIN", {"DOMAIN": "AE"}) == "AE" + assert evaluate_jsonata("MISSING", {"DOMAIN": "AE"}) is None + + +def test_jsonata_path_navigation(): + ctx = {"Dataset": {"Variable": "USUBJID"}} + assert evaluate_jsonata("Dataset.Variable", ctx) == "USUBJID" + + +def test_jsonata_comparison(): + ctx = {"DOMAIN": "AE", "AGE": 30} + assert evaluate_jsonata('DOMAIN = "AE"', ctx) is True + assert evaluate_jsonata('DOMAIN != "DM"', ctx) is True + assert evaluate_jsonata("AGE > 25", ctx) is True + assert evaluate_jsonata("AGE < 25", ctx) is False + assert evaluate_jsonata("AGE >= 30", ctx) is True + assert evaluate_jsonata("AGE <= 30", ctx) is True + + +def test_jsonata_arithmetic(): + assert evaluate_jsonata("2 + 3", {}) == 5 + assert evaluate_jsonata("10 - 4", {}) == 6 + assert evaluate_jsonata("3 * 4", {}) == 12 + assert evaluate_jsonata("10 / 4", {}) == 2.5 + + +def test_jsonata_boolean_operators(): + assert evaluate_jsonata("true and true", {}) is True + assert evaluate_jsonata("true and false", {}) is False + assert evaluate_jsonata("false or true", {}) is True + assert evaluate_jsonata("not(false)", {}) is True + assert evaluate_jsonata("not(true)", {}) is False + + +def test_jsonata_grouped(): + assert evaluate_jsonata("(2 + 3) * 4", {}) == 20 + + +def test_jsonata_string_functions(): + assert evaluate_jsonata('$uppercase("ae")', {}) == "AE" + assert evaluate_jsonata('$lowercase("AE")', {}) == "ae" + assert evaluate_jsonata('$string(42)', {}) == "42" + assert evaluate_jsonata('$length("hello")', {}) == 5 + assert evaluate_jsonata('$trim(" hi ")', {}) == "hi" + + +def test_jsonata_substring(): + assert evaluate_jsonata('$substring("STUDYID", 0, 5)', {}) == "STUDY" + assert evaluate_jsonata('$substring("STUDYID", 5)', {}) == "ID" + assert evaluate_jsonata('$substring("hello", -3)', {}) == "llo" + + +def test_jsonata_aggregate_functions(): + assert evaluate_jsonata("$count(VALS)", {"VALS": [1, 2, 3]}) == 3 + assert evaluate_jsonata("$count(VALS)", {"VALS": None}) == 0 + assert evaluate_jsonata("$exists(X)", {"X": "y"}) is True + assert evaluate_jsonata("$exists(X)", {"X": None}) is False + assert evaluate_jsonata("$count($distinct(VALS))", {"VALS": [1, 2, 2, 3]}) == 3 + + +def test_jsonata_context_field_expression(): + ctx = {"DOMAIN": "AE", "USUBJID": "S01"} + assert evaluate_jsonata('$uppercase(DOMAIN) = "AE"', ctx) is True + assert evaluate_jsonata("$length(DOMAIN) = 2", ctx) is True + + +def test_jsonata_not_supported_filter(): + import pytest + # Filter expressions VALS[...] are not supported; raises either + # JSONataNotSupported (when reached during evaluation) or JSONataSyntaxError + # (when the parser hits unexpected '[' after consuming VALS). + with pytest.raises((JSONataNotSupported, JSONataSyntaxError)): + evaluate_jsonata("VALS[0]", {"VALS": [1, 2]}) + + +def test_jsonata_syntax_error(): + import pytest + with pytest.raises(JSONataSyntaxError): + evaluate_jsonata("= broken", {}) + + +# ── Phase 2: new operations ─────────────────────────────────────────────────── + + +def test_has_required_variables_all_present(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import polars as pl + import narwhals as nw + + df = nw.from_native(pl.DataFrame({"STUDYID": ["X"], "DOMAIN": ["DM"], "USUBJID": ["U1"]}), eager_only=True) + ct = ControlledTerminology({}, []) + ops = [{"operator": "has_required_variables", "params": {"variables": ["STUDYID", "DOMAIN", "USUBJID"]}}] + result = apply_operations(df, ops, ct, {}) + assert result["_pb_STUDYID_present"].to_list() == [True] + assert result["_pb_DOMAIN_present"].to_list() == [True] + assert result["_pb_USUBJID_present"].to_list() == [True] + + +def test_has_required_variables_missing(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import polars as pl + import narwhals as nw + + df = nw.from_native(pl.DataFrame({"STUDYID": ["X"]}), eager_only=True) + ct = ControlledTerminology({}, []) + ops = [{"operator": "has_required_variables", "params": {"variables": ["STUDYID", "DOMAIN"]}}] + result = apply_operations(df, ops, ct, {}) + assert result["_pb_STUDYID_present"].to_list() == [True] + assert result["_pb_DOMAIN_present"].to_list() == [False] + + +def test_valid_variable_order_correct(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import polars as pl + import narwhals as nw + + df = nw.from_native(pl.DataFrame({"STUDYID": ["X"], "DOMAIN": ["DM"], "USUBJID": ["U1"]}), eager_only=True) + ct = ControlledTerminology({}, []) + ops = [{"operator": "valid_variable_order", "params": {"expected_order": ["STUDYID", "DOMAIN", "USUBJID"]}}] + result = apply_operations(df, ops, ct, {}) + assert result["_pb_variable_order_valid"].to_list() == [True] + + +def test_valid_variable_order_wrong(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import polars as pl + import narwhals as nw + + # DOMAIN appears before STUDYID + df = nw.from_native(pl.DataFrame({"DOMAIN": ["DM"], "STUDYID": ["X"], "USUBJID": ["U1"]}), eager_only=True) + ct = ControlledTerminology({}, []) + ops = [{"operator": "valid_variable_order", "params": {"expected_order": ["STUDYID", "DOMAIN", "USUBJID"]}}] + result = apply_operations(df, ops, ct, {}) + assert result["_pb_variable_order_valid"].to_list() == [False] + + +def test_valid_variable_order_absent_columns_skipped(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import polars as pl + import narwhals as nw + + # DOMAIN absent; remaining two are in order → True + df = nw.from_native(pl.DataFrame({"STUDYID": ["X"], "USUBJID": ["U1"]}), eager_only=True) + ct = ControlledTerminology({}, []) + ops = [{"operator": "valid_variable_order", "params": {"expected_order": ["STUDYID", "DOMAIN", "USUBJID"]}}] + result = apply_operations(df, ops, ct, {}) + assert result["_pb_variable_order_valid"].to_list() == [True] + + +def test_variable_type_check_numeric_ok(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import polars as pl + import narwhals as nw + + df = nw.from_native(pl.DataFrame({"AGE": [45.0]}), eager_only=True) + ct = ControlledTerminology({}, []) + ops = [{"operator": "variable_type_check", "params": {"column": "AGE", "expected_type": "numeric"}}] + result = apply_operations(df, ops, ct, {}) + assert result["_pb_AGE_type_valid"].to_list() == [True] + + +def test_variable_type_check_numeric_fail(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import polars as pl + import narwhals as nw + + df = nw.from_native(pl.DataFrame({"AGE": ["45"]}), eager_only=True) # string, not numeric + ct = ControlledTerminology({}, []) + ops = [{"operator": "variable_type_check", "params": {"column": "AGE", "expected_type": "numeric"}}] + result = apply_operations(df, ops, ct, {}) + assert result["_pb_AGE_type_valid"].to_list() == [False] + + +def test_variable_type_check_absent_column_passes(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import polars as pl + import narwhals as nw + + df = nw.from_native(pl.DataFrame({"STUDYID": ["X"]}), eager_only=True) + ct = ControlledTerminology({}, []) + ops = [{"operator": "variable_type_check", "params": {"column": "AGE", "expected_type": "numeric"}}] + result = apply_operations(df, ops, ct, {}) + assert result["_pb_AGE_type_valid"].to_list() == [True] + + +# ── Phase 2: VARIABLE_METADATA_CHECK engine integration ────────────────────── + + +def _full_dm() -> pl.DataFrame: + return pl.DataFrame({ + "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["S1-001"], "SUBJID": ["001"], + "RFSTDTC": ["2020-01-01"], "RFENDTC": ["2020-06-30"], + "SITEID": ["001"], "AGE": [45.0], "AGEU": ["YEARS"], + "SEX": ["M"], "RACE": ["WHITE"], "ETHNIC": ["NOT HISPANIC OR LATINO"], + "COUNTRY": ["USA"], "ARMCD": ["A"], "ARM": ["Arm A"], + "ACTARMCD": ["A"], "ACTARM": ["Arm A"], + }) + + +def test_variable_metadata_check_passes_for_complete_dm(): + engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["VARIABLE_METADATA_CHECK"]) + result = engine.run({"DM": _full_dm()}) + vmc = [r for r in result.rule_results if r.rule_type == "VARIABLE_METADATA_CHECK"] + assert len(vmc) > 0 + # With a complete DM, all Fully Executable VMC rules on DM should pass. + dm_rules = [r for r in vmc if "DM" in r.dataset and r.status not in ("not_supported", "not_applicable")] + assert all(r.status == "pass" for r in dm_rules), [(r.rule_id, r.status, r.message) for r in dm_rules] + + +def test_variable_metadata_check_fails_missing_sex(): + dm = _full_dm().drop("SEX") + engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["VARIABLE_METADATA_CHECK"]) + result = engine.run({"DM": dm}) + vmc = {r.rule_id: r for r in result.rule_results if r.rule_type == "VARIABLE_METADATA_CHECK"} + # SDTM-032 checks SEX, RACE, ETHNIC, COUNTRY. + assert vmc["SDTM-032"].status == "fail" + + +def test_variable_metadata_check_fails_wrong_order(): + dm = _full_dm().select(["DOMAIN", "STUDYID"] + [c for c in _full_dm().columns if c not in ("DOMAIN", "STUDYID")]) + engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["VARIABLE_METADATA_CHECK"]) + result = engine.run({"DM": dm}) + vmc = {r.rule_id: r for r in result.rule_results if r.rule_type == "VARIABLE_METADATA_CHECK"} + assert vmc["SDTM-044"].status == "fail" + + +def test_partially_executable_returns_not_applicable(): + engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["VARIABLE_METADATA_CHECK"]) + result = engine.run({"DM": _full_dm()}) + # SDTM-049 and SDTM-050 require DEFINE dataset + partial = {r.rule_id: r for r in result.rule_results if r.rule_id in ("SDTM-049", "SDTM-050")} + assert partial["SDTM-049"].status == "not_applicable" + assert partial["SDTM-050"].status == "not_applicable" + assert "not provided" in (partial["SDTM-049"].message or "") + + +def test_partially_executable_runs_when_dataset_provided(): + # When the required dataset IS present, the rule should not return not_applicable. + engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["VARIABLE_METADATA_CHECK"]) + # SDTM-049/050 have empty conditions so they'll pass on any dataset. + result = engine.run({"DM": _full_dm(), "DEFINE": pl.DataFrame({"col": ["x"]})}) + partial = {r.rule_id: r for r in result.rule_results if r.rule_id in ("SDTM-049", "SDTM-050")} + assert all(r.status != "not_applicable" for r in partial.values()) From eb9af79429b6dec497ea3250562a063d4a83e447 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 21:30:15 -0400 Subject: [PATCH 48/93] =?UTF-8?q?Add=20SDTM-051=E2=80=93060=20Define-XML?= =?UTF-8?q?=20conformance=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/conformance/rules/sdtmig-3-4.json | 610 +++++++++++++++++- 1 file changed, 608 insertions(+), 2 deletions(-) diff --git a/pointblank/data/conformance/rules/sdtmig-3-4.json b/pointblank/data/conformance/rules/sdtmig-3-4.json index 6215920e3..91d54d2f2 100644 --- a/pointblank/data/conformance/rules/sdtmig-3-4.json +++ b/pointblank/data/conformance/rules/sdtmig-3-4.json @@ -1,9 +1,9 @@ { "standard": "sdtmig", "version": "3.4", - "generated": "2026-07-13T21:56:24Z", + "generated": "2026-07-14T01:14:47Z", "source": "CDISC SDTM Implementation Guide 3.4, hand-curated from public specification", - "checksum": "4d210bbc1f1ba8e4", + "checksum": "70df23d302c99e9b", "rules": [ { "core_id": "SDTM-001", @@ -2300,6 +2300,612 @@ "message": "Variable data type does not match Define-XML specification." } } + }, + { + "core_id": "SDTM-051", + "rule_type": "DEFINE_ITEM_METADATA_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "Each variable in the DM dataset must be declared in Define-XML.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [ + "DEFINE" + ], + "operations": [ + { + "operator": "define_var_declared", + "params": { + "column": "STUDYID" + } + }, + { + "operator": "define_var_declared", + "params": { + "column": "DOMAIN" + } + }, + { + "operator": "define_var_declared", + "params": { + "column": "USUBJID" + } + }, + { + "operator": "define_var_declared", + "params": { + "column": "SEX" + } + }, + { + "operator": "define_var_declared", + "params": { + "column": "AGE" + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_in_define", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_in_define", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_in_define", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_SEX_in_define", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AGE_in_define", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "One or more DM variables are not declared in Define-XML." + } + } + }, + { + "core_id": "SDTM-052", + "rule_type": "DEFINE_ITEM_METADATA_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "Each variable in the AE dataset must be declared in Define-XML.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [ + "DEFINE" + ], + "operations": [ + { + "operator": "define_var_declared", + "params": { + "column": "STUDYID" + } + }, + { + "operator": "define_var_declared", + "params": { + "column": "DOMAIN" + } + }, + { + "operator": "define_var_declared", + "params": { + "column": "USUBJID" + } + }, + { + "operator": "define_var_declared", + "params": { + "column": "AESEQ" + } + }, + { + "operator": "define_var_declared", + "params": { + "column": "AETERM" + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_in_define", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_in_define", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_in_define", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AESEQ_in_define", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AETERM_in_define", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "One or more AE variables are not declared in Define-XML." + } + } + }, + { + "core_id": "SDTM-053", + "rule_type": "DEFINE_ITEM_METADATA_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "Variables declared Mandatory in Define-XML must not contain null values in DM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [ + "DEFINE" + ], + "operations": [ + { + "operator": "define_required_check", + "params": { + "column": "STUDYID" + } + }, + { + "operator": "define_required_check", + "params": { + "column": "DOMAIN" + } + }, + { + "operator": "define_required_check", + "params": { + "column": "USUBJID" + } + }, + { + "operator": "define_required_check", + "params": { + "column": "SEX" + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_mandatory_ok", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_mandatory_ok", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_mandatory_ok", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_SEX_mandatory_ok", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "A mandatory variable (Mandatory=Yes in Define-XML) contains a null value." + } + } + }, + { + "core_id": "SDTM-054", + "rule_type": "DEFINE_ITEM_METADATA_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "Variables declared Mandatory in Define-XML must not contain null values in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [ + "DEFINE" + ], + "operations": [ + { + "operator": "define_required_check", + "params": { + "column": "STUDYID" + } + }, + { + "operator": "define_required_check", + "params": { + "column": "USUBJID" + } + }, + { + "operator": "define_required_check", + "params": { + "column": "AESEQ" + } + }, + { + "operator": "define_required_check", + "params": { + "column": "AETERM" + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_mandatory_ok", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_mandatory_ok", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AESEQ_mandatory_ok", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AETERM_mandatory_ok", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "A mandatory AE variable (Mandatory=Yes in Define-XML) contains a null value." + } + } + }, + { + "core_id": "SDTM-055", + "rule_type": "DEFINE_ITEM_METADATA_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "Variable data types in DM must match the types declared in Define-XML.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [ + "DEFINE" + ], + "operations": [ + { + "operator": "define_type_check", + "params": { + "column": "STUDYID" + } + }, + { + "operator": "define_type_check", + "params": { + "column": "AGE" + } + }, + { + "operator": "define_type_check", + "params": { + "column": "SEX" + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_define_type_ok", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_AGE_define_type_ok", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_SEX_define_type_ok", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "A variable's data type does not match the type declared in Define-XML." + } + } + }, + { + "core_id": "SDTM-056", + "rule_type": "DEFINE_CODELIST_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "SEX values in DM must be from the codelist declared in Define-XML.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [ + "DEFINE" + ], + "operations": [ + { + "operator": "define_codelist_check", + "params": { + "column": "SEX" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_SEX_define_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DM.SEX contains a value not in the Define-XML declared codelist." + } + } + }, + { + "core_id": "SDTM-057", + "rule_type": "DEFINE_CODELIST_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "RACE values in DM must be from the codelist declared in Define-XML.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [ + "DEFINE" + ], + "operations": [ + { + "operator": "define_codelist_check", + "params": { + "column": "RACE" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_RACE_define_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DM.RACE contains a value not in the Define-XML declared codelist." + } + } + }, + { + "core_id": "SDTM-058", + "rule_type": "DEFINE_CODELIST_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "ETHNIC values in DM must be from the codelist declared in Define-XML.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [ + "DEFINE" + ], + "operations": [ + { + "operator": "define_codelist_check", + "params": { + "column": "ETHNIC" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_ETHNIC_define_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DM.ETHNIC contains a value not in the Define-XML declared codelist." + } + } + }, + { + "core_id": "SDTM-059", + "rule_type": "DEFINE_CODELIST_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "AESEV values in AE must be from the codelist declared in Define-XML.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [ + "DEFINE" + ], + "operations": [ + { + "operator": "define_codelist_check", + "params": { + "column": "AESEV" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AESEV_define_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AE.AESEV contains a value not in the Define-XML declared codelist." + } + } + }, + { + "core_id": "SDTM-060", + "rule_type": "DEFINE_CODELIST_CHECK", + "executability": "Partially Executable", + "sensitivity": "Error", + "description": "AEOUT values in AE must be from the codelist declared in Define-XML.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [ + "DEFINE" + ], + "operations": [ + { + "operator": "define_codelist_check", + "params": { + "column": "AEOUT" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AEOUT_define_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AE.AEOUT contains a value not in the Define-XML declared codelist." + } + } } ] } \ No newline at end of file From 3853f7a6ea2d44379e665aa59173aa9f157e02fe Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 21:30:35 -0400 Subject: [PATCH 49/93] Add define.xml metadata loading helpers --- pointblank/metadata/_conformance/engine.py | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/pointblank/metadata/_conformance/engine.py b/pointblank/metadata/_conformance/engine.py index 7e8b5b315..b8b7a3528 100644 --- a/pointblank/metadata/_conformance/engine.py +++ b/pointblank/metadata/_conformance/engine.py @@ -99,6 +99,37 @@ def run(self, datasets: dict[str, Any]) -> NativeConformanceResult: rule_results=results, ) + def _load_define_xml(self, define_xml: Any) -> MetadataPackage | None: + if define_xml is None: + return None + from pointblank.metadata._types import MetadataImport, MetadataPackage + + if isinstance(define_xml, MetadataPackage): + return define_xml + if isinstance(define_xml, MetadataImport): + domain = (define_xml.domain or define_xml.dataset_name or "").upper() + pkg = MetadataPackage() + pkg.items[domain] = define_xml + return pkg + # Assume path + try: + from pointblank.metadata._readers_cdisc import _read_define_xml_metadata + except ImportError: + return None + result = _read_define_xml_metadata(str(define_xml)) + from pointblank.metadata._types import MetadataPackage as _Pkg, MetadataImport as _MI + if isinstance(result, _Pkg): + return result + pkg = _Pkg() + domain = (result.domain or result.dataset_name or "").upper() + pkg.items[domain] = result + return pkg + + def _define_meta_for(self, domain: str) -> MetadataImport | None: + if self._define_pkg is None: + return None + return self._define_pkg.items.get(domain.upper()) + # ── Rule dispatch ───────────────────────────────────────────────────────── def _evaluate_rule( From aa116057e60bbd4e6ca36422a4701eec18640fcb Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 21:32:48 -0400 Subject: [PATCH 50/93] Add Define-XML metadata check handlers --- pointblank/metadata/_conformance/engine.py | 29 +++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/pointblank/metadata/_conformance/engine.py b/pointblank/metadata/_conformance/engine.py index b8b7a3528..0530e2c16 100644 --- a/pointblank/metadata/_conformance/engine.py +++ b/pointblank/metadata/_conformance/engine.py @@ -257,6 +257,7 @@ def _dataset_metadata_check( continue # Operations add the computed columns that conditions reference. df = apply_operations(df, rule.operations, self._ct, datasets) + df = apply_operations(df, rule.operations, self._ct, datasets, define_meta) # For metadata checks the condition is evaluated once against a single-row summary # DataFrame (all computed columns). If any operation added a False column, the # condition fires. @@ -285,13 +286,33 @@ def _variable_metadata_check( ) -> NativeRuleResult: """Variable-level metadata check (presence, order, type). - Delegates to ``_dataset_metadata_check``; semantically distinct from - ``DATASET_METADATA_CHECK`` (which checks dataset-level attributes like sort keys - or record count) but evaluated identically — operations add scalar broadcast - columns that conditions then test. + Delegates to `_dataset_metadata_check`; semantically distinct from `DATASET_METADATA_CHECK` + (which checks dataset-level attributes like sort keys or record count) but evaluated + identically. Operations add scalar broadcast columns that conditions then test. """ return self._dataset_metadata_check(rule, datasets) + def _define_item_metadata_check( + self, rule: NativeRule, datasets: dict[str, nw.DataFrame] + ) -> NativeRuleResult: + """Check submission variables against Define-XML item declarations. + + Operations add broadcast computed columns (e.g. `_pb_SEX_in_define`, `_pb_SEX_mandatory_ok`) + using the domain's `MetadataImport`; conditions then test those columns. Behaves like a + dataset-level metadata check. + """ + return self._dataset_metadata_check(rule, datasets) + + def _define_codelist_check( + self, rule: NativeRule, datasets: dict[str, nw.DataFrame] + ) -> NativeRuleResult: + """Check that codelist values in the submission match Define-XML declarations. + + Per-row check: `define_codelist_check` operations add `_pb__define_valid` columns; + conditions flag rows where the value is outside the declared codelist. + """ + return self._record_check(rule, datasets) + def _domain_presence_check( self, rule: NativeRule, datasets: dict[str, nw.DataFrame] ) -> NativeRuleResult: From 6e0f60044fad058b9f1efd194d191b77a4932167 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 21:33:27 -0400 Subject: [PATCH 51/93] Add Define-XML support to conformance engine --- pointblank/metadata/_conformance/engine.py | 46 +++++++++++++++++----- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/pointblank/metadata/_conformance/engine.py b/pointblank/metadata/_conformance/engine.py index 0530e2c16..8e517f524 100644 --- a/pointblank/metadata/_conformance/engine.py +++ b/pointblank/metadata/_conformance/engine.py @@ -6,10 +6,13 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import narwhals as nw +if TYPE_CHECKING: + from pointblank.metadata._types import MetadataImport, MetadataPackage + from pointblank.metadata._conformance.ct import ControlledTerminology from pointblank.metadata._conformance.evaluator import EvaluationError, evaluate_conditions from pointblank.metadata._conformance.operations import apply_operations @@ -32,9 +35,11 @@ "DOMAIN_PRESENCE_CHECK", "DATASET_CONTENTS_CHECK", "VARIABLE_METADATA_CHECK", + "DEFINE_ITEM_METADATA_CHECK", + "DEFINE_CODELIST_CHECK", } -# Maximum row-level findings to collect per rule (avoids blowing up memory on large datasets). +# Maximum row-level findings to collect per rule (avoids blowing up memory on large datasets) _MAX_FINDINGS = 100 @@ -68,18 +73,30 @@ def __init__( self._ct = ControlledTerminology.load_default() else: self._ct = ControlledTerminology.load(ct_packages) + self._define_pkg: MetadataPackage | None = None @property def ct_packages(self) -> list[str]: return self._ct.packages - def run(self, datasets: dict[str, Any]) -> NativeConformanceResult: + def run( + self, + datasets: dict[str, Any], + define_xml: Any = None, + ) -> NativeConformanceResult: """Evaluate all rules against `datasets`. Parameters ---------- datasets Mapping of domain name (e.g. `"DM"`) to a Pandas or Polars DataFrame. + define_xml + Optional Define-XML metadata. Accepted forms: + + * A file path (`str` or `pathlib.Path`): the file is parsed automatically. + * A `MetadataPackage` object (already parsed). + * A `MetadataImport` object (single domain). + * `None`: no Define-XML; rules that require it return `STATUS_NOT_APPLICABLE`. Returns ------- @@ -88,6 +105,7 @@ def run(self, datasets: dict[str, Any]) -> NativeConformanceResult: nw_datasets: dict[str, nw.DataFrame] = { k.upper(): nw.from_native(v, eager_only=True) for k, v in datasets.items() } + self._define_pkg: MetadataPackage | None = self._load_define_xml(define_xml) results: list[NativeRuleResult] = [] for rule in self._rules: result = self._evaluate_rule(rule, nw_datasets) @@ -117,7 +135,8 @@ def _load_define_xml(self, define_xml: Any) -> MetadataPackage | None: except ImportError: return None result = _read_define_xml_metadata(str(define_xml)) - from pointblank.metadata._types import MetadataPackage as _Pkg, MetadataImport as _MI + from pointblank.metadata._types import MetadataPackage as _Pkg + if isinstance(result, _Pkg): return result pkg = _Pkg() @@ -145,10 +164,16 @@ def _evaluate_rule( description=rule.description, ) - # Partially Executable rules require extra datasets (e.g. Define XML metadata). - # Return NOT_APPLICABLE instead of failing when those inputs are absent. + # Partially Executable rules require extra inputs. + # "DEFINE" is satisfied by define_xml; all others must be present as DataFrame keys. if rule.executability == "Partially Executable": - missing_ds = [d for d in rule.datasets if d.upper() not in datasets] + missing_ds = [] + for d in rule.datasets: + if d.upper() == "DEFINE": + if self._define_pkg is None: + missing_ds.append(d) + elif d.upper() not in datasets: + missing_ds.append(d) if missing_ds: return NativeRuleResult( rule_id=rule.core_id, @@ -166,6 +191,8 @@ def _evaluate_rule( "DOMAIN_PRESENCE_CHECK": self._domain_presence_check, "DATASET_CONTENTS_CHECK": self._dataset_contents_check, "VARIABLE_METADATA_CHECK": self._variable_metadata_check, + "DEFINE_ITEM_METADATA_CHECK": self._define_item_metadata_check, + "DEFINE_CODELIST_CHECK": self._define_codelist_check, }[rule.rule_type] try: @@ -197,7 +224,8 @@ def _record_check( df = datasets.get(domain.upper()) if df is None: continue - df = apply_operations(df, rule.operations, self._ct, datasets) + define_meta = self._define_meta_for(domain) + df = apply_operations(df, rule.operations, self._ct, datasets, define_meta) try: mask = evaluate_conditions(df, rule.conditions) except EvaluationError: @@ -255,8 +283,8 @@ def _dataset_metadata_check( df = datasets.get(domain.upper()) if df is None: continue + define_meta = self._define_meta_for(domain) # Operations add the computed columns that conditions reference. - df = apply_operations(df, rule.operations, self._ct, datasets) df = apply_operations(df, rule.operations, self._ct, datasets, define_meta) # For metadata checks the condition is evaluated once against a single-row summary # DataFrame (all computed columns). If any operation added a False column, the From 33a779b397e3ba29ec285e3e7b5847bb337536e5 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 21:36:22 -0400 Subject: [PATCH 52/93] Add Define-XML conformance operations --- .../metadata/_conformance/operations.py | 181 ++++++++++++++++-- 1 file changed, 164 insertions(+), 17 deletions(-) diff --git a/pointblank/metadata/_conformance/operations.py b/pointblank/metadata/_conformance/operations.py index 8a5e3ece6..4107e2ce5 100644 --- a/pointblank/metadata/_conformance/operations.py +++ b/pointblank/metadata/_conformance/operations.py @@ -8,14 +8,21 @@ Registered operations --------------------- -codelist_check -- _pb__valid (True = value in codelist or null) -consistency_check -- _pb__consistent (True = value matches dataset mode, or null) -iso8601_check -- _pb__iso8601 (True = valid ISO 8601 partial/full datetime or null) -unique_per_subject -- _pb__unique (True = value is unique within the USUBJID group) -column_presence -- _pb__present (True = column exists in the dataset, scalar broadcast) -has_required_variables -- _pb__present (batch column_presence for a list of columns) +codelist_check -- _pb__valid (True = value in codelist or null) +consistency_check -- _pb__consistent (True = value matches dataset mode, or null) +iso8601_check -- _pb__iso8601 (True = valid ISO 8601 partial/full datetime or null) +unique_per_subject -- _pb__unique (True = value is unique within the USUBJID group) +column_presence -- _pb__present (True = column exists in the dataset, scalar broadcast) +has_required_variables -- _pb__present (batch column_presence for a list of columns) valid_variable_order -- _pb_variable_order_valid (True = columns appear in expected relative order) -variable_type_check -- _pb__type_valid (True = column dtype matches expected category) +variable_type_check -- _pb__type_valid (True = column dtype matches expected category) + +Define-XML–aware operations (require define_meta to be non-None; always True otherwise) +---------------------------------------------------------------------------------------- +define_var_declared -- _pb__in_define (True = variable is declared in Define-XML) +define_required_check -- _pb__mandatory_ok (True = Mandatory variable has no nulls; row-level) +define_codelist_check -- _pb__define_valid (True = value is in Define-XML codelist; row-level) +define_type_check -- _pb__define_type_ok (True = dtype matches Define-XML declared type) """ from __future__ import annotations @@ -39,8 +46,17 @@ def apply_operations( operations: list[dict], ct: ControlledTerminology, datasets: dict[str, nw.DataFrame], + define_meta: Any = None, ) -> nw.DataFrame: - """Apply all operations to `df`, returning an enriched DataFrame.""" + """Apply all operations to `df`, returning an enriched DataFrame. + + Parameters + ---------- + define_meta + Optional `MetadataImport` for the current domain, used by Define-XML-aware operations. Pass + `None` (the default) when no Define-XML has been provided; those operations will return + `True` (pass) without flagging anything. + """ for op in operations: operator = op.get("operator", "") params = op.get("params", {}) @@ -48,7 +64,7 @@ def apply_operations( if handler is None: continue try: - df = handler(df, params, ct, datasets) + df = handler(df, params, ct, datasets, define_meta) except Exception: pass # a failing operation silently skips; conditions that reference its column won't fire return df @@ -62,8 +78,9 @@ def _op_codelist_check( params: dict, ct: ControlledTerminology, datasets: dict[str, nw.DataFrame], + define_meta: Any = None, ) -> nw.DataFrame: - """Add `_pb__valid` (True = value in codelist or null).""" + """Add `_pb__valid` (`True` = value in codelist or null).""" col: str = params["column"] codelist: str = params["codelist"] result_col = f"_pb_{col}_valid" @@ -82,8 +99,9 @@ def _op_consistency_check( params: dict, ct: ControlledTerminology, datasets: dict[str, nw.DataFrame], + define_meta: Any = None, ) -> nw.DataFrame: - """Add `_pb__consistent` (True = value equals the mode, or null).""" + """Add `_pb__consistent` (`True` = value equals the mode, or null).""" col: str = params["column"] result_col = f"_pb_{col}_consistent" if col not in df.columns: @@ -102,8 +120,9 @@ def _op_iso8601_check( params: dict, ct: ControlledTerminology, datasets: dict[str, nw.DataFrame], + define_meta: Any = None, ) -> nw.DataFrame: - """Add `_pb__iso8601` (True = valid ISO 8601 partial/complete datetime or null).""" + """Add `_pb__iso8601` (`True` = valid ISO 8601 partial/complete datetime or null).""" col: str = params["column"] result_col = f"_pb_{col}_iso8601" if col not in df.columns: @@ -118,8 +137,9 @@ def _op_unique_per_subject( params: dict, ct: ControlledTerminology, datasets: dict[str, nw.DataFrame], + define_meta: Any = None, ) -> nw.DataFrame: - """Add `_pb__unique` (True = value is unique within the USUBJID group).""" + """Add `_pb__unique` (`True` = value is unique within the USUBJID group).""" col: str = params["column"] result_col = f"_pb_{col}_unique" if col not in df.columns or "USUBJID" not in df.columns: @@ -142,8 +162,9 @@ def _op_column_presence( params: dict, ct: ControlledTerminology, datasets: dict[str, nw.DataFrame], + define_meta: Any = None, ) -> nw.DataFrame: - """Add `_pb__present` broadcast scalar (True = column exists in the dataset).""" + """Add `_pb__present` broadcast scalar (`True` = column exists in the dataset).""" col: str = params["column"] result_col = f"_pb_{col}_present" present = col in df.columns @@ -155,6 +176,7 @@ def _op_has_required_variables( params: dict, ct: ControlledTerminology, datasets: dict[str, nw.DataFrame], + define_meta: Any = None, ) -> nw.DataFrame: """Add `_pb__present` broadcast columns for each variable in `params["variables"]`. @@ -172,11 +194,12 @@ def _op_valid_variable_order( params: dict, ct: ControlledTerminology, datasets: dict[str, nw.DataFrame], + define_meta: Any = None, ) -> nw.DataFrame: """Add `_pb_variable_order_valid` broadcast scalar. Checks that the variables listed in `params["expected_order"]` appear in the correct relative - order when both are present. Variables absent from the dataset are skipped (presence is a + order when both are present. Variables absent from the dataset are skipped (presence is a separate check). Result is `True` if no order violation is found. """ expected: list[str] = params.get("expected_order", []) @@ -197,12 +220,13 @@ def _op_variable_type_check( params: dict, ct: ControlledTerminology, datasets: dict[str, nw.DataFrame], + define_meta: Any = None, ) -> nw.DataFrame: """Add `_pb__type_valid` broadcast scalar. Checks whether the column's dtype category matches `params["expected_type"]`. Accepted values - for `expected_type`: `"character"` (string/object) or `"numeric"` (integer/float). If the - column is absent, result is `True`. + for `expected_type`: `"character"` (string/object) or `"numeric"` (integer/float). If the column + is absent, result is `True`. """ col: str = params["column"] expected: str = params.get("expected_type", "character").lower() @@ -223,6 +247,125 @@ def _op_variable_type_check( return df.with_columns(nw.lit(valid).alias(result_col)) +def _op_define_var_declared( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], + define_meta: Any = None, +) -> nw.DataFrame: + """Add `_pb__in_define` broadcast scalar (True = variable is declared in Define-XML). + + If no Define-XML metadata is available, always returns `True` so the rule does not fire. + """ + col: str = params["column"] + result_col = f"_pb_{col}_in_define" + if define_meta is None: + return df.with_columns(nw.lit(True).alias(result_col)) + declared = {v.name.upper() for v in define_meta.variables} + return df.with_columns(nw.lit(col.upper() in declared).alias(result_col)) + + +def _op_define_required_check( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], + define_meta: Any = None, +) -> nw.DataFrame: + """Add `_pb__mandatory_ok` per-row (True = value is non-null when Define-XML says mandatory). + + Rows where the variable is not mandatory, or Define-XML is absent, all get `True`. + """ + col: str = params["column"] + result_col = f"_pb_{col}_mandatory_ok" + if define_meta is None or col not in df.columns: + return df.with_columns(nw.lit(True).alias(result_col)) + var_meta = next((v for v in define_meta.variables if v.name.upper() == col.upper()), None) + if var_meta is None or not var_meta.required: + return df.with_columns(nw.lit(True).alias(result_col)) + # True = ok (value present); False = null where mandatory + mask = [v is not None for v in df[col].to_list()] + return df.with_columns(_new_bool_series(result_col, mask, df)) + + +def _op_define_codelist_check( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], + define_meta: Any = None, +) -> nw.DataFrame: + """Add `_pb__define_valid` per-row (True = value is in the Define-XML codelist or null). + + Uses `allowed_values` from the variable's `VariableMetadata`. If the variable has no codelist + reference in Define-XML, or Define-XML is absent, always returns `True`. + """ + col: str = params["column"] + result_col = f"_pb_{col}_define_valid" + if define_meta is None or col not in df.columns: + return df.with_columns(nw.lit(True).alias(result_col)) + var_meta = next((v for v in define_meta.variables if v.name.upper() == col.upper()), None) + if var_meta is None or not var_meta.allowed_values: + return df.with_columns(nw.lit(True).alias(result_col)) + allowed = {str(v) for v in var_meta.allowed_values} + values = df[col].to_list() + mask = [True if v is None else (str(v) in allowed) for v in values] + return df.with_columns(_new_bool_series(result_col, mask, df)) + + +def _op_define_type_check( + df: nw.DataFrame, + params: dict, + ct: ControlledTerminology, + datasets: dict[str, nw.DataFrame], + define_meta: Any = None, +) -> nw.DataFrame: + """Add `_pb__define_type_ok` broadcast scalar (True = dtype matches Define-XML declared type). + + Translates the `display_format` (raw CDISC type) to a character/numeric category and compares it + against the column's actual narwhals dtype. If Define-XML is absent or the column is not + declared, returns `True`. + """ + col: str = params["column"] + result_col = f"_pb_{col}_define_type_ok" + if define_meta is None or col not in df.columns: + return df.with_columns(nw.lit(True).alias(result_col)) + var_meta = next((v for v in define_meta.variables if v.name.upper() == col.upper()), None) + if var_meta is None: + return df.with_columns(nw.lit(True).alias(result_col)) + + # Map Define-XML type to expected category + from narwhals import dtypes as _dtypes + + dtype = df[col].dtype + is_numeric = isinstance(dtype, _dtypes.NumericType) + + define_type = (var_meta.display_format or var_meta.dtype or "").lower() + numeric_types = {"integer", "float", "numeric", "int64", "float64", "int32", "float32"} + char_types = { + "text", + "string", + "character", + "char", + "datetime", + "date", + "time", + "partialdate", + "partialdatetime", + "durationdatetime", + "incompletedatetime", + } + if any(t in define_type for t in numeric_types): + valid = is_numeric + elif any(t in define_type for t in char_types): + valid = not is_numeric + else: + valid = True # unknown type → don't flag + + return df.with_columns(nw.lit(valid).alias(result_col)) + + _REGISTRY: dict[str, Any] = { "codelist_check": _op_codelist_check, "consistency_check": _op_consistency_check, @@ -232,4 +375,8 @@ def _op_variable_type_check( "has_required_variables": _op_has_required_variables, "valid_variable_order": _op_valid_variable_order, "variable_type_check": _op_variable_type_check, + "define_var_declared": _op_define_var_declared, + "define_required_check": _op_define_required_check, + "define_codelist_check": _op_define_codelist_check, + "define_type_check": _op_define_type_check, } From 264b441ed40aa8e04cbab67355fcf7229dcbcb28 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 21:36:39 -0400 Subject: [PATCH 53/93] Pass define.xml into native conformance engine --- pointblank/metadata/_submission.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pointblank/metadata/_submission.py b/pointblank/metadata/_submission.py index bdb9cbef8..55d169162 100644 --- a/pointblank/metadata/_submission.py +++ b/pointblank/metadata/_submission.py @@ -407,6 +407,7 @@ def validate_conformance( standard: str | None = None, version: str | None = None, ct_packages: list[str] | None = None, + define_xml: Any = None, controlled_terminology: str | Sequence[str] | None = None, core: str | Sequence[str] | None = None, core_cwd: str | Path | None = None, @@ -500,7 +501,8 @@ def validate_conformance( std = standard or self.standard ver = version or self.standard_version rules_report = self._run_rules_conformance( - agency=agency, standard=std, version=ver, ct_packages=ct_packages + agency=agency, standard=std, version=ver, ct_packages=ct_packages, + define_xml=define_xml, ) if rules_report is not None: return rules_report @@ -526,6 +528,7 @@ def _run_rules_conformance( standard: str, version: str, ct_packages: list[str] | None, + define_xml: Any = None, ) -> ConformanceReport | None: """Run the native rule-based engine; returns None if no catalog is bundled.""" from pointblank.metadata._conformance.engine import NativeConformanceEngine @@ -537,7 +540,11 @@ def _run_rules_conformance( engine = NativeConformanceEngine( standard=standard, version=version, ct_packages=ct_packages ) - result = engine.run(self.datasets) + # Prefer explicit define_xml argument; fall back to package's define path. + _define = define_xml if define_xml is not None else ( + self.define if isinstance(self.define, (str, Path)) else None + ) + result = engine.run(self.datasets, define_xml=_define) return ConformanceReport(native_result=result, package=self, agency=agency) def _run_core_conformance( From 949d38aa1ba06c0332c73c3d828d12dbf9f2bf20 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 21:37:01 -0400 Subject: [PATCH 54/93] Expand native conformance tests for Define-XML --- tests/test_native_conformance.py | 269 ++++++++++++++++++++++++++++++- 1 file changed, 267 insertions(+), 2 deletions(-) diff --git a/tests/test_native_conformance.py b/tests/test_native_conformance.py index 0149a6490..947b75ee5 100644 --- a/tests/test_native_conformance.py +++ b/tests/test_native_conformance.py @@ -21,6 +21,7 @@ from pointblank.metadata._conformance.evaluator import evaluate_conditions, is_iso8601 from pointblank.metadata._conformance.operations import apply_operations from pointblank.metadata._conformance.result import STATUS_FAIL, STATUS_PASS +from pointblank.metadata._types import MetadataImport, MetadataPackage, VariableMetadata import narwhals as nw @@ -345,7 +346,7 @@ def test_engine_clean_dm_zero_issues(clean_result): def test_engine_rule_count(clean_result): - assert len(clean_result.rule_results) == 50 + assert len(clean_result.rule_results) == 60 def test_engine_result_types(clean_result): @@ -823,6 +824,270 @@ def test_partially_executable_runs_when_dataset_provided(): # When the required dataset IS present, the rule should not return not_applicable. engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["VARIABLE_METADATA_CHECK"]) # SDTM-049/050 have empty conditions so they'll pass on any dataset. - result = engine.run({"DM": _full_dm(), "DEFINE": pl.DataFrame({"col": ["x"]})}) + stub_define = MetadataPackage(items={"DM": MetadataImport(source_format="cdisc_define", dataset_name="DM")}) + result = engine.run({"DM": _full_dm()}, define_xml=stub_define) partial = {r.rule_id: r for r in result.rule_results if r.rule_id in ("SDTM-049", "SDTM-050")} assert all(r.status != "not_applicable" for r in partial.values()) + + +# ── Phase 3: Define-XML operations and handlers ─────────────────────────────── + + +def _make_var(name: str, dtype: str = "String", required: bool = False, allowed_values=None, display_format: str | None = None) -> VariableMetadata: + return VariableMetadata(name=name, dtype=dtype, required=required, allowed_values=allowed_values, display_format=display_format) + + +def _make_define_pkg(domain: str, variables: list[VariableMetadata]) -> MetadataPackage: + meta = MetadataImport(source_format="cdisc_define", dataset_name=domain, domain=domain, variables=variables) + return MetadataPackage(items={domain.upper(): meta}) + + +def test_define_var_declared_present(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import narwhals as nw + + define_meta = MetadataImport( + source_format="cdisc_define", dataset_name="DM", domain="DM", + variables=[_make_var("STUDYID"), _make_var("SEX")], + ) + df = nw.from_native(pl.DataFrame({"STUDYID": ["X"], "SEX": ["M"]}), eager_only=True) + ct = ControlledTerminology({}, []) + ops = [ + {"operator": "define_var_declared", "params": {"column": "STUDYID"}}, + {"operator": "define_var_declared", "params": {"column": "MISSING_VAR"}}, + ] + result = apply_operations(df, ops, ct, {}, define_meta) + assert result["_pb_STUDYID_in_define"].to_list() == [True] + assert result["_pb_MISSING_VAR_in_define"].to_list() == [False] + + +def test_define_var_declared_no_define_meta(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import narwhals as nw + + df = nw.from_native(pl.DataFrame({"STUDYID": ["X"]}), eager_only=True) + ops = [{"operator": "define_var_declared", "params": {"column": "ANYTHING"}}] + result = apply_operations(df, ops, ControlledTerminology({}, []), {}, None) + assert result["_pb_ANYTHING_in_define"].to_list() == [True] + + +def test_define_required_check_passes_non_null(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import narwhals as nw + + define_meta = MetadataImport( + source_format="cdisc_define", dataset_name="DM", domain="DM", + variables=[_make_var("STUDYID", required=True)], + ) + df = nw.from_native(pl.DataFrame({"STUDYID": ["S1", "S2"]}), eager_only=True) + ops = [{"operator": "define_required_check", "params": {"column": "STUDYID"}}] + result = apply_operations(df, ops, ControlledTerminology({}, []), {}, define_meta) + assert result["_pb_STUDYID_mandatory_ok"].to_list() == [True, True] + + +def test_define_required_check_flags_null(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import narwhals as nw + + define_meta = MetadataImport( + source_format="cdisc_define", dataset_name="DM", domain="DM", + variables=[_make_var("STUDYID", required=True)], + ) + df = nw.from_native(pl.DataFrame({"STUDYID": ["S1", None]}), eager_only=True) + ops = [{"operator": "define_required_check", "params": {"column": "STUDYID"}}] + result = apply_operations(df, ops, ControlledTerminology({}, []), {}, define_meta) + assert result["_pb_STUDYID_mandatory_ok"].to_list() == [True, False] + + +def test_define_required_check_not_mandatory_always_true(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import narwhals as nw + + define_meta = MetadataImport( + source_format="cdisc_define", dataset_name="DM", domain="DM", + variables=[_make_var("OPTIONAL_VAR", required=False)], + ) + df = nw.from_native(pl.DataFrame({"OPTIONAL_VAR": ["X", None]}), eager_only=True) + ops = [{"operator": "define_required_check", "params": {"column": "OPTIONAL_VAR"}}] + result = apply_operations(df, ops, ControlledTerminology({}, []), {}, define_meta) + assert result["_pb_OPTIONAL_VAR_mandatory_ok"].to_list() == [True, True] + + +def test_define_codelist_check_valid_values(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import narwhals as nw + + define_meta = MetadataImport( + source_format="cdisc_define", dataset_name="DM", domain="DM", + variables=[_make_var("SEX", allowed_values=["M", "F", "U"])], + ) + df = nw.from_native(pl.DataFrame({"SEX": ["M", "F", "INVALID"]}), eager_only=True) + ops = [{"operator": "define_codelist_check", "params": {"column": "SEX"}}] + result = apply_operations(df, ops, ControlledTerminology({}, []), {}, define_meta) + assert result["_pb_SEX_define_valid"].to_list() == [True, True, False] + + +def test_define_codelist_check_null_passes(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import narwhals as nw + + define_meta = MetadataImport( + source_format="cdisc_define", dataset_name="DM", domain="DM", + variables=[_make_var("SEX", allowed_values=["M", "F"])], + ) + df = nw.from_native(pl.DataFrame({"SEX": ["M", None]}), eager_only=True) + ops = [{"operator": "define_codelist_check", "params": {"column": "SEX"}}] + result = apply_operations(df, ops, ControlledTerminology({}, []), {}, define_meta) + assert result["_pb_SEX_define_valid"].to_list() == [True, True] + + +def test_define_codelist_check_no_codelist_always_true(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import narwhals as nw + + define_meta = MetadataImport( + source_format="cdisc_define", dataset_name="DM", domain="DM", + variables=[_make_var("NOTES")], # no allowed_values + ) + df = nw.from_native(pl.DataFrame({"NOTES": ["anything"]}), eager_only=True) + ops = [{"operator": "define_codelist_check", "params": {"column": "NOTES"}}] + result = apply_operations(df, ops, ControlledTerminology({}, []), {}, define_meta) + assert result["_pb_NOTES_define_valid"].to_list() == [True] + + +def test_define_type_check_numeric_ok(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import narwhals as nw + + define_meta = MetadataImport( + source_format="cdisc_define", dataset_name="DM", domain="DM", + variables=[_make_var("AGE", dtype="Float64", display_format="float")], + ) + df = nw.from_native(pl.DataFrame({"AGE": [45.0]}), eager_only=True) + ops = [{"operator": "define_type_check", "params": {"column": "AGE"}}] + result = apply_operations(df, ops, ControlledTerminology({}, []), {}, define_meta) + assert result["_pb_AGE_define_type_ok"].to_list() == [True] + + +def test_define_type_check_char_mismatch(): + from pointblank.metadata._conformance.operations import apply_operations + from pointblank.metadata._conformance.ct import ControlledTerminology + import narwhals as nw + + define_meta = MetadataImport( + source_format="cdisc_define", dataset_name="DM", domain="DM", + variables=[_make_var("STUDYID", display_format="text")], + ) + df = nw.from_native(pl.DataFrame({"STUDYID": [1, 2]}), eager_only=True) # numeric, not text + ops = [{"operator": "define_type_check", "params": {"column": "STUDYID"}}] + result = apply_operations(df, ops, ControlledTerminology({}, []), {}, define_meta) + assert result["_pb_STUDYID_define_type_ok"].to_list() == [False, False] + + +# ── Phase 3: engine integration ─────────────────────────────────────────────── + + +def _dm_with_bad_sex() -> pl.DataFrame: + return pl.DataFrame({ + "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["S1-001"], "SUBJID": ["001"], + "SEX": ["INVALID"], "RACE": ["WHITE"], "ETHNIC": ["NOT HISPANIC OR LATINO"], + "COUNTRY": ["USA"], "AGE": [45.0], "AGEU": ["YEARS"], "SITEID": ["001"], + "RFSTDTC": ["2020-01-01"], "RFENDTC": ["2020-06-30"], + "ARMCD": ["A"], "ARM": ["Arm A"], "ACTARMCD": ["A"], "ACTARM": ["Arm A"], + }) + + +def _dm_define_pkg() -> MetadataPackage: + return _make_define_pkg("DM", [ + _make_var("STUDYID", required=True), + _make_var("DOMAIN", required=True), + _make_var("USUBJID", required=True), + _make_var("SEX", allowed_values=["M", "F", "U", "UNDIFFERENTIATED"]), + _make_var("RACE", allowed_values=["WHITE", "BLACK OR AFRICAN AMERICAN", "ASIAN"]), + _make_var("ETHNIC", allowed_values=["NOT HISPANIC OR LATINO", "HISPANIC OR LATINO"]), + _make_var("AGE", dtype="Float64", display_format="float"), + ]) + + +def test_define_item_metadata_check_all_declared(): + engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["DEFINE_ITEM_METADATA_CHECK"]) + pkg = _dm_define_pkg() + result = engine.run({"DM": _clean_dm()}, define_xml=pkg) + sdtm_051 = next(r for r in result.rule_results if r.rule_id == "SDTM-051") + assert sdtm_051.status == "pass" + + +def test_define_item_metadata_check_undeclared_variable(): + engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["DEFINE_ITEM_METADATA_CHECK"]) + # Provide a Define-XML that does NOT declare DOMAIN + pkg = _make_define_pkg("DM", [ + _make_var("STUDYID", required=True), + _make_var("USUBJID"), _make_var("SEX"), _make_var("AGE", display_format="float"), + ]) + result = engine.run({"DM": _clean_dm()}, define_xml=pkg) + sdtm_051 = next(r for r in result.rule_results if r.rule_id == "SDTM-051") + assert sdtm_051.status == "fail" + + +def test_define_codelist_check_flags_bad_value(): + engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["DEFINE_CODELIST_CHECK"]) + pkg = _dm_define_pkg() + result = engine.run({"DM": _dm_with_bad_sex()}, define_xml=pkg) + sdtm_056 = next(r for r in result.rule_results if r.rule_id == "SDTM-056") + assert sdtm_056.status == "fail" + assert sdtm_056.n_issues == 1 + + +def test_define_codelist_check_passes_valid_values(): + engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["DEFINE_CODELIST_CHECK"]) + pkg = _dm_define_pkg() + result = engine.run({"DM": _clean_dm()}, define_xml=pkg) + sdtm_056 = next(r for r in result.rule_results if r.rule_id == "SDTM-056") + assert sdtm_056.status == "pass" + + +def test_define_rules_not_applicable_without_define_xml(): + engine = NativeConformanceEngine( + "sdtmig", "3.4", + rule_types=["DEFINE_ITEM_METADATA_CHECK", "DEFINE_CODELIST_CHECK"], + ) + result = engine.run({"DM": _clean_dm()}) # no define_xml + for r in result.rule_results: + assert r.status == "not_applicable", f"{r.rule_id} was {r.status}" + + +def test_define_rules_applicable_with_define_xml(): + engine = NativeConformanceEngine( + "sdtmig", "3.4", + rule_types=["DEFINE_ITEM_METADATA_CHECK", "DEFINE_CODELIST_CHECK"], + ) + result = engine.run({"DM": _clean_dm()}, define_xml=_dm_define_pkg()) + statuses = {r.status for r in result.rule_results} + assert "not_applicable" not in statuses + + +def test_engine_accepts_metadata_import_directly(): + engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["DEFINE_ITEM_METADATA_CHECK"]) + dm_meta = MetadataImport( + source_format="cdisc_define", dataset_name="DM", domain="DM", + variables=[_make_var("STUDYID", required=True), _make_var("DOMAIN"), _make_var("USUBJID"), + _make_var("SEX"), _make_var("AGE", display_format="float")], + ) + result = engine.run({"DM": _clean_dm()}, define_xml=dm_meta) + sdtm_051 = next(r for r in result.rule_results if r.rule_id == "SDTM-051") + assert sdtm_051.status in ("pass", "fail") # executed, not not_applicable + + +def test_engine_rule_count_phase3(): + engine = NativeConformanceEngine("sdtmig", "3.4") + result = engine.run({"DM": _clean_dm()}) + assert len(result.rule_results) == 60 From 0b6b25daae66882749ff731ef597738b2422efb4 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 21:53:51 -0400 Subject: [PATCH 55/93] Add new codelists to SDTM CT JSON --- .../conformance/ct/sdtm-ct-2024-09-27.json | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json b/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json index a3eb883ae..52472f285 100644 --- a/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json +++ b/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json @@ -249,6 +249,78 @@ "YEM", "ZMB", "ZWE" + ], + "AGEU": [ + "YEARS", + "MONTHS", + "WEEKS", + "DAYS", + "HOURS", + "MINUTES", + "SECONDS" + ], + "VSRESU": [ + "mmHg", + "kg", + "m", + "cm", + "%", + "beats/min", + "breaths/min", + "/min", + "C", + "F", + "kg/m2", + "bpm" + ], + "LBSTRESU": [ + "g/dL", + "mg/dL", + "mmol/L", + "U/L", + "g/L", + "mg/L", + "%", + "10^3/uL", + "10^6/uL", + "IU/L", + "pg/mL", + "ng/mL", + "ug/mL", + "mEq/L", + "fL", + "pg", + "seconds", + "ratio", + "nmol/L", + "umol/L" + ], + "NRIND": [ + "LOW", + "NORMAL", + "HIGH", + "CRITICALLY LOW", + "CRITICALLY HIGH", + "CRITICALLY ABNORMAL", + "ABNORMAL", + "NORMAL LOW", + "NORMAL HIGH" + ], + "IECAT": [ + "INCLUSION", + "EXCLUSION" + ], + "AEACN": [ + "DOSE NOT CHANGED", + "DOSE REDUCED", + "DOSE INCREASED", + "DOSE RATE REDUCED", + "DOSE DELAYED", + "INTERRUPTED", + "DISCONTINUED", + "NOT APPLICABLE", + "UNKNOWN", + "NOT REPORTED" ] } -} \ No newline at end of file +} From 3bd7b175778c4a35cb79ab117a36935fe8096c4f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 21:54:17 -0400 Subject: [PATCH 56/93] Expand SDTM 3.4 conformance rule coverage --- .../data/conformance/rules/sdtmig-3-4.json | 2751 ++++++++++++++++- 1 file changed, 2748 insertions(+), 3 deletions(-) diff --git a/pointblank/data/conformance/rules/sdtmig-3-4.json b/pointblank/data/conformance/rules/sdtmig-3-4.json index 91d54d2f2..a9ccf0a92 100644 --- a/pointblank/data/conformance/rules/sdtmig-3-4.json +++ b/pointblank/data/conformance/rules/sdtmig-3-4.json @@ -1,9 +1,9 @@ { "standard": "sdtmig", "version": "3.4", - "generated": "2026-07-14T01:14:47Z", + "generated": "2026-07-14T01:51:57Z", "source": "CDISC SDTM Implementation Guide 3.4, hand-curated from public specification", - "checksum": "70df23d302c99e9b", + "checksum": "a8cc350537f3a361", "rules": [ { "core_id": "SDTM-001", @@ -2906,6 +2906,2751 @@ "message": "AE.AEOUT contains a value not in the Define-XML declared codelist." } } + }, + { + "core_id": "SDTM-061", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AGEU in DM must use AGEU codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AGEU", + "codelist": "AGEU" + } + } + ], + "conditions": { + "all": [ + { + "name": "AGEU", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AGEU_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AGEU value is not in the AGEU codelist." + } + } + }, + { + "core_id": "SDTM-062", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "BRTHDTC in DM must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "BRTHDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "BRTHDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_BRTHDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "BRTHDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-063", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXTRT must not be null in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EXTRT", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXTRT must not be null in EX." + } + } + }, + { + "core_id": "SDTM-064", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXSTDTC in EX must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "EXSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "EXSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EXSTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXSTDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-065", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXENDTC in EX must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "EXENDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "EXENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EXENDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXENDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-066", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXDOSFRQ in EX must use FREQ codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "EXDOSFRQ", + "codelist": "FREQ" + } + } + ], + "conditions": { + "all": [ + { + "name": "EXDOSFRQ", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EXDOSFRQ_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXDOSFRQ value is not in the FREQ codelist." + } + } + }, + { + "core_id": "SDTM-067", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXROUTE in EX must use ROUTE codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "EXROUTE", + "codelist": "ROUTE" + } + } + ], + "conditions": { + "all": [ + { + "name": "EXROUTE", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EXROUTE_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXROUTE value is not in the ROUTE codelist." + } + } + }, + { + "core_id": "SDTM-068", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXDOSFRM in EX must use FRM codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "EXDOSFRM", + "codelist": "FRM" + } + } + ], + "conditions": { + "all": [ + { + "name": "EXDOSFRM", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EXDOSFRM_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXDOSFRM value is not in the FRM codelist." + } + } + }, + { + "core_id": "SDTM-069", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMTRT must not be null in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "CMTRT", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMTRT must not be null in CM." + } + } + }, + { + "core_id": "SDTM-070", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMSTDTC in CM must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "CMSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "CMSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_CMSTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMSTDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-071", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMENDTC in CM must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "CMENDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "CMENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_CMENDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMENDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-072", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMROUTE in CM must use ROUTE codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "CMROUTE", + "codelist": "ROUTE" + } + } + ], + "conditions": { + "all": [ + { + "name": "CMROUTE", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_CMROUTE_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMROUTE value is not in the ROUTE codelist." + } + } + }, + { + "core_id": "SDTM-073", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSTERM must not be null in DS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DSTERM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DSTERM must not be null in DS." + } + } + }, + { + "core_id": "SDTM-074", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSDECOD must not be null in DS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DSDECOD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DSDECOD must not be null in DS." + } + } + }, + { + "core_id": "SDTM-075", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSSTDTC in DS must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "DSSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "DSSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_DSSTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DSSTDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-076", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSSEQ must not be null in DS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DSSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DSSEQ must not be null in DS." + } + } + }, + { + "core_id": "SDTM-077", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHTERM must not be null in MH.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "MHTERM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "MHTERM must not be null in MH." + } + } + }, + { + "core_id": "SDTM-078", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHSTDTC in MH must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "MHSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "MHSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_MHSTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "MHSTDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-079", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHENDTC in MH must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "MHENDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "MHENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_MHENDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "MHENDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-080", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must not be null in SV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VISITNUM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VISITNUM must not be null in SV." + } + } + }, + { + "core_id": "SDTM-081", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "SVSTDTC in SV must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "SVSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "SVSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_SVSTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "SVSTDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-082", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "SVENDTC in SV must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "SVENDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "SVENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_SVENDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "SVENDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-083", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGTEST must not be null in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EGTEST", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EGTEST must not be null in EG." + } + } + }, + { + "core_id": "SDTM-084", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGTESTCD must not be null in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EGTESTCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EGTESTCD must not be null in EG." + } + } + }, + { + "core_id": "SDTM-085", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGDTC in EG must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "EGDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "EGDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EGDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EGDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-086", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESEV in AE must use AESEV codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AESEV", + "codelist": "AESEV" + } + } + ], + "conditions": { + "all": [ + { + "name": "AESEV", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AESEV_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESEV value is not in the AESEV codelist." + } + } + }, + { + "core_id": "SDTM-087", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AETOXGR in AE must use AETOXGR codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AETOXGR", + "codelist": "AETOXGR" + } + } + ], + "conditions": { + "all": [ + { + "name": "AETOXGR", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AETOXGR_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AETOXGR value is not in the AETOXGR codelist." + } + } + }, + { + "core_id": "SDTM-088", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AEBODSYS must not be null in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "AEBODSYS", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AEBODSYS must not be null in AE." + } + } + }, + { + "core_id": "SDTM-089", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AEACN in AE must use AEACN codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AEACN", + "codelist": "AEACN" + } + } + ], + "conditions": { + "all": [ + { + "name": "AEACN", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AEACN_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AEACN value is not in the AEACN codelist." + } + } + }, + { + "core_id": "SDTM-090", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESEQ must not be null in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "AESEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESEQ must not be null in AE." + } + } + }, + { + "core_id": "SDTM-091", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSTESTCD must not be null in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VSTESTCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSTESTCD must not be null in VS." + } + } + }, + { + "core_id": "SDTM-092", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSSTRESU in VS must use VSRESU codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "VSSTRESU", + "codelist": "VSRESU" + } + } + ], + "conditions": { + "all": [ + { + "name": "VSSTRESU", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_VSSTRESU_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSSTRESU value is not in the VSRESU codelist." + } + } + }, + { + "core_id": "SDTM-093", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSBLFL in VS must use NY codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "VSBLFL", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "VSBLFL", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_VSBLFL_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSBLFL value is not in the NY codelist." + } + } + }, + { + "core_id": "SDTM-094", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSTRESU in LB must use LBSTRESU codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "LBSTRESU", + "codelist": "LBSTRESU" + } + } + ], + "conditions": { + "all": [ + { + "name": "LBSTRESU", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_LBSTRESU_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBSTRESU value is not in the LBSTRESU codelist." + } + } + }, + { + "core_id": "SDTM-095", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBBLFL in LB must use NY codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "LBBLFL", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "LBBLFL", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_LBBLFL_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBBLFL value is not in the NY codelist." + } + } + }, + { + "core_id": "SDTM-096", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBNRIND in LB must use NRIND codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "LBNRIND", + "codelist": "NRIND" + } + } + ], + "conditions": { + "all": [ + { + "name": "LBNRIND", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_LBNRIND_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBNRIND value is not in the NRIND codelist." + } + } + }, + { + "core_id": "SDTM-097", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PETEST must not be null in PE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "PETEST", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "PETEST must not be null in PE." + } + } + }, + { + "core_id": "SDTM-098", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PEDTC in PE must conform to ISO 8601 when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "PEDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "PEDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_PEDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "PEDTC does not conform to ISO 8601 extended datetime format." + } + } + }, + { + "core_id": "SDTM-099", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IETEST must not be null in IE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "IE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IETEST", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IETEST must not be null in IE." + } + } + }, + { + "core_id": "SDTM-100", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IECAT in IE must use IECAT codelist when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "IE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "IECAT", + "codelist": "IECAT" + } + } + ], + "conditions": { + "all": [ + { + "name": "IECAT", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_IECAT_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IECAT value is not in the IECAT codelist." + } + } + }, + { + "core_id": "SDTM-101", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RDOMAIN must not be null in SUPP-- datasets.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDM", + "SUPPAE", + "SUPPLB", + "SUPPVS", + "SUPPEG", + "SUPPCM", + "SUPPDS", + "SUPPMH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "RDOMAIN", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RDOMAIN must not be null in SUPP-- datasets." + } + } + }, + { + "core_id": "SDTM-102", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QNAM must not be null in SUPP-- datasets.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDM", + "SUPPAE", + "SUPPLB", + "SUPPVS", + "SUPPEG", + "SUPPCM", + "SUPPDS", + "SUPPMH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QNAM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QNAM must not be null in SUPP-- datasets." + } + } + }, + { + "core_id": "SDTM-103", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QLABEL must not be null in SUPP-- datasets.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDM", + "SUPPAE", + "SUPPLB", + "SUPPVS", + "SUPPEG", + "SUPPCM", + "SUPPDS", + "SUPPMH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QLABEL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QLABEL must not be null in SUPP-- datasets." + } + } + }, + { + "core_id": "SDTM-104", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TSPARMCD must not be null in TS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TSPARMCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TSPARMCD must not be null in TS." + } + } + }, + { + "core_id": "SDTM-105", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TSPARM must not be null in TS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TSPARM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TSPARM must not be null in TS." + } + } + }, + { + "core_id": "SDTM-106", + "rule_type": "DATASET_CONTENTS_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "STUDYID must be consistent (same value) across all records within each domain.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], + "domains": [], + "datasets": [], + "operations": [ + { + "operator": "consistency_check", + "params": { + "column": "STUDYID" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_STUDYID_consistent", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_dataset_error", + "params": { + "message": "STUDYID must be consistent across all records within a domain." + } + } + }, + { + "core_id": "SDTM-107", + "rule_type": "DATASET_CONTENTS_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN must be consistent (same value) across all records within each dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "All" + ], + "domains": [], + "datasets": [], + "operations": [ + { + "operator": "consistency_check", + "params": { + "column": "DOMAIN" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_DOMAIN_consistent", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_dataset_error", + "params": { + "message": "DOMAIN must be consistent across all records within a dataset." + } + } + }, + { + "core_id": "SDTM-108", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EX domain must contain required identifier and treatment variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "EXSEQ", + "EXTRT" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_EXSEQ_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_EXTRT_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EX domain is missing one or more required variables (STUDYID, DOMAIN, USUBJID, EXSEQ, EXTRT)." + } + } + }, + { + "core_id": "SDTM-109", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EX domain must contain timing variables EXSTDTC and EXENDTC.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "EXSTDTC", + "EXENDTC" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_EXSTDTC_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_EXENDTC_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EX domain is missing one or more required timing variables (EXSTDTC, EXENDTC)." + } + } + }, + { + "core_id": "SDTM-110", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CM domain must contain required identifier and treatment variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "CMSEQ", + "CMTRT" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_CMSEQ_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_CMTRT_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "CM domain is missing one or more required variables (STUDYID, DOMAIN, USUBJID, CMSEQ, CMTRT)." + } + } + }, + { + "core_id": "SDTM-111", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CM domain must contain timing variables CMSTDTC and CMENDTC.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "CMSTDTC", + "CMENDTC" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_CMSTDTC_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_CMENDTC_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "CM domain is missing one or more required timing variables (CMSTDTC, CMENDTC)." + } + } + }, + { + "core_id": "SDTM-112", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DS domain must contain required identifier and disposition variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "DSSEQ", + "DSTERM", + "DSDECOD" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DSSEQ_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DSTERM_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DSDECOD_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "DS domain is missing one or more required variables (STUDYID, DOMAIN, USUBJID, DSSEQ, DSTERM, DSDECOD)." + } + } + }, + { + "core_id": "SDTM-113", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MH domain must contain required identifier and history variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "MHSEQ", + "MHTERM" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_MHSEQ_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_MHTERM_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "MH domain is missing one or more required variables (STUDYID, DOMAIN, USUBJID, MHSEQ, MHTERM)." + } + } + }, + { + "core_id": "SDTM-114", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "SV domain must contain required identifier and visit variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "VISITNUM", + "SVSTDTC" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_VISITNUM_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_SVSTDTC_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "SV domain is missing one or more required variables (STUDYID, DOMAIN, USUBJID, VISITNUM, SVSTDTC)." + } + } + }, + { + "core_id": "SDTM-115", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TS domain must contain required parameter variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TS" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "TSPARMCD", + "TSPARM", + "TSVAL" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_TSPARMCD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_TSPARM_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_TSVAL_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TS domain is missing one or more required variables (STUDYID, DOMAIN, TSPARMCD, TSPARM, TSVAL)." + } + } + }, + { + "core_id": "SDTM-116", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IE domain must contain required inclusion/exclusion criteria variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "IE" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "IESEQ", + "IETESTCD", + "IETEST", + "IECAT", + "IEORRES" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_IESEQ_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_IETESTCD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_IETEST_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_IECAT_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_IEORRES_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "IE domain is missing one or more required variables (STUDYID, DOMAIN, USUBJID, IESEQ, IETESTCD, IETEST, IECAT, IEORRES)." + } + } + }, + { + "core_id": "SDTM-117", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PE domain must contain required physical examination variables.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "USUBJID", + "PESEQ", + "PETEST", + "PEORRES" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_USUBJID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_PESEQ_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_PETEST_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_PEORRES_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "PE domain is missing one or more required variables (STUDYID, DOMAIN, USUBJID, PESEQ, PETEST, PEORRES)." + } + } + }, + { + "core_id": "SDTM-118", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXDOSE must be a numeric variable in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EXDOSE", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXDOSE_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXDOSE must be a numeric variable in EX." + } + } + }, + { + "core_id": "SDTM-119", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must be a numeric variable in SV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VISITNUM", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VISITNUM_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VISITNUM must be a numeric variable in SV." + } + } + }, + { + "core_id": "SDTM-120", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGSTRESN must be a numeric variable in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EGSTRESN", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EGSTRESN_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EGSTRESN must be a numeric variable in EG." + } + } } ] -} \ No newline at end of file +} From b07a4bffed7d8706b06e8e4d710aa92e016e7a11 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 21:54:25 -0400 Subject: [PATCH 57/93] Update test_native_conformance.py --- tests/test_native_conformance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_native_conformance.py b/tests/test_native_conformance.py index 947b75ee5..15b6e1f50 100644 --- a/tests/test_native_conformance.py +++ b/tests/test_native_conformance.py @@ -346,7 +346,7 @@ def test_engine_clean_dm_zero_issues(clean_result): def test_engine_rule_count(clean_result): - assert len(clean_result.rule_results) == 60 + assert len(clean_result.rule_results) == 120 def test_engine_result_types(clean_result): @@ -1090,4 +1090,4 @@ def test_engine_accepts_metadata_import_directly(): def test_engine_rule_count_phase3(): engine = NativeConformanceEngine("sdtmig", "3.4") result = engine.run({"DM": _clean_dm()}) - assert len(result.rule_results) == 60 + assert len(result.rule_results) == 120 From bb1628fc9b0b87e18f5d5f0deaab96c8452a099a Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 23:58:49 -0400 Subject: [PATCH 58/93] Expand SDTMIG 3.4 conformance rule coverage --- .../data/conformance/rules/sdtmig-3-4.json | 12205 +++++++++++++++- 1 file changed, 12201 insertions(+), 4 deletions(-) diff --git a/pointblank/data/conformance/rules/sdtmig-3-4.json b/pointblank/data/conformance/rules/sdtmig-3-4.json index a9ccf0a92..8e32107ad 100644 --- a/pointblank/data/conformance/rules/sdtmig-3-4.json +++ b/pointblank/data/conformance/rules/sdtmig-3-4.json @@ -1,9 +1,9 @@ { "standard": "sdtmig", "version": "3.4", - "generated": "2026-07-14T01:51:57Z", + "generated": "2026-07-14T03:44:03Z", "source": "CDISC SDTM Implementation Guide 3.4, hand-curated from public specification", - "checksum": "a8cc350537f3a361", + "checksum": "e060bbc7d0171f3a", "rules": [ { "core_id": "SDTM-001", @@ -1064,7 +1064,7 @@ "rule_type": "DATASET_METADATA_CHECK", "executability": "Fully Executable", "sensitivity": "Warning", - "description": "USUBJID must be present in every SDTM domain.", + "description": "USUBJID must be present in every subject-level SDTM domain.", "authority": "CDISC", "standards": [ "sdtmig" @@ -1072,7 +1072,28 @@ "classes": [ "All" ], - "domains": [], + "domains": [ + "DM", + "AE", + "CM", + "DS", + "EX", + "LB", + "MH", + "PE", + "VS", + "EG", + "IE", + "SV", + "SUPPDM", + "SUPPAE", + "SUPPLB", + "SUPPVS", + "SUPPEG", + "SUPPCM", + "SUPPDS", + "SUPPMH" + ], "datasets": [], "operations": [ { @@ -5651,6 +5672,12182 @@ "message": "EGSTRESN must be a numeric variable in EG." } } + }, + { + "core_id": "SDTM-121", + "rule_type": "DOMAIN_PRESENCE_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TS domain must be present in every SDTM submission.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [], + "domains": [], + "datasets": [], + "operations": [], + "conditions": {}, + "actions": { + "id": "domain_presence", + "params": { + "required_domains": [ + "TS" + ], + "prohibited_domains": [], + "message": "TS domain must be present in every SDTM submission." + } + } + }, + { + "core_id": "SDTM-122", + "rule_type": "DOMAIN_PRESENCE_CHECK", + "executability": "Fully Executable", + "sensitivity": "Warning", + "description": "TA domain must be present in every SDTM submission.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [], + "domains": [], + "datasets": [], + "operations": [], + "conditions": {}, + "actions": { + "id": "domain_presence", + "params": { + "required_domains": [ + "TA" + ], + "prohibited_domains": [], + "message": "TA domain must be present in every SDTM submission." + } + } + }, + { + "core_id": "SDTM-123", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "ARMCD must not be null in TA.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "ARMCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "ARMCD must not be null in TA." + } + } + }, + { + "core_id": "SDTM-124", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "ARM must not be null in TA.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "ARM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "ARM must not be null in TA." + } + } + }, + { + "core_id": "SDTM-125", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TAETORD must not be null in TA.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TAETORD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TAETORD must not be null in TA." + } + } + }, + { + "core_id": "SDTM-126", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EPOCH must not be null in TA.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EPOCH", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EPOCH must not be null in TA." + } + } + }, + { + "core_id": "SDTM-127", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "ETCD must not be null in TE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "ETCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "ETCD must not be null in TE." + } + } + }, + { + "core_id": "SDTM-128", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "ELEMENT must not be null in TE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "ELEMENT", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "ELEMENT must not be null in TE." + } + } + }, + { + "core_id": "SDTM-129", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must not be null in TV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VISITNUM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VISITNUM must not be null in TV." + } + } + }, + { + "core_id": "SDTM-130", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISIT must not be null in TV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VISIT", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VISIT must not be null in TV." + } + } + }, + { + "core_id": "SDTM-131", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IETESTCD must not be null in TI.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IETESTCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IETESTCD must not be null in TI." + } + } + }, + { + "core_id": "SDTM-132", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IETEST must not be null in TI.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IETEST", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IETEST must not be null in TI." + } + } + }, + { + "core_id": "SDTM-133", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IECAT in TI must use IECAT controlled terminology when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "IECAT", + "codelist": "IECAT" + } + } + ], + "conditions": { + "all": [ + { + "name": "IECAT", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_IECAT_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IECAT in TI contains a value not in the IECAT codelist." + } + } + }, + { + "core_id": "SDTM-134", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IEINCLCR in TI must use NY controlled terminology when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "IEINCLCR", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "IEINCLCR", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_IEINCLCR_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IEINCLCR in TI contains a value not in the NY codelist." + } + } + }, + { + "core_id": "SDTM-135", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AEREL in AE must use AEREL controlled terminology when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AEREL", + "codelist": "AEREL" + } + } + ], + "conditions": { + "all": [ + { + "name": "AEREL", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AEREL_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AEREL in AE contains a value not in the AEREL codelist." + } + } + }, + { + "core_id": "SDTM-136", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESDTH in AE must use NY controlled terminology when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AESDTH", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "AESDTH", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AESDTH_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESDTH in AE contains a value not in the NY codelist." + } + } + }, + { + "core_id": "SDTM-137", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESHOSP in AE must use NY controlled terminology when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AESHOSP", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "AESHOSP", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AESHOSP_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESHOSP in AE contains a value not in the NY codelist." + } + } + }, + { + "core_id": "SDTM-138", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESLIFE in AE must use NY controlled terminology when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AESLIFE", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "AESLIFE", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AESLIFE_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESLIFE in AE contains a value not in the NY codelist." + } + } + }, + { + "core_id": "SDTM-139", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXSEQ must not be null in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EXSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXSEQ must not be null in EX." + } + } + }, + { + "core_id": "SDTM-140", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMSEQ must not be null in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "CMSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMSEQ must not be null in CM." + } + } + }, + { + "core_id": "SDTM-141", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHSEQ must not be null in MH.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "MHSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "MHSEQ must not be null in MH." + } + } + }, + { + "core_id": "SDTM-142", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGSEQ must not be null in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EGSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EGSEQ must not be null in EG." + } + } + }, + { + "core_id": "SDTM-143", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSSEQ must not be null in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VSSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSSEQ must not be null in VS." + } + } + }, + { + "core_id": "SDTM-144", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSEQ must not be null in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "LBSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBSEQ must not be null in LB." + } + } + }, + { + "core_id": "SDTM-145", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PESEQ must not be null in PE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "PESEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "PESEQ must not be null in PE." + } + } + }, + { + "core_id": "SDTM-146", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IESEQ must not be null in IE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "IE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IESEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IESEQ must not be null in IE." + } + } + }, + { + "core_id": "SDTM-147", + "rule_type": "DATASET_CONTENTS_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESEQ must be unique within USUBJID in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "unique_per_subject", + "params": { + "column": "AESEQ" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AESEQ_unique", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESEQ is not unique within USUBJID in AE." + } + } + }, + { + "core_id": "SDTM-148", + "rule_type": "DATASET_CONTENTS_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXSEQ must be unique within USUBJID in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "unique_per_subject", + "params": { + "column": "EXSEQ" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXSEQ_unique", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXSEQ is not unique within USUBJID in EX." + } + } + }, + { + "core_id": "SDTM-149", + "rule_type": "DATASET_CONTENTS_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMSEQ must be unique within USUBJID in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "unique_per_subject", + "params": { + "column": "CMSEQ" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_CMSEQ_unique", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMSEQ is not unique within USUBJID in CM." + } + } + }, + { + "core_id": "SDTM-150", + "rule_type": "DATASET_CONTENTS_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSEQ must be unique within USUBJID in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "unique_per_subject", + "params": { + "column": "LBSEQ" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSEQ_unique", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBSEQ is not unique within USUBJID in LB." + } + } + }, + { + "core_id": "SDTM-151", + "rule_type": "DATASET_CONTENTS_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSSEQ must be unique within USUBJID in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "unique_per_subject", + "params": { + "column": "VSSEQ" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VSSEQ_unique", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSSEQ is not unique within USUBJID in VS." + } + } + }, + { + "core_id": "SDTM-152", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESTDY must be numeric type in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "AESTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AESTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "AESTDY in AE must be numeric type." + } + } + }, + { + "core_id": "SDTM-153", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AEENDY must be numeric type in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "AEENDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AEENDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "AEENDY in AE must be numeric type." + } + } + }, + { + "core_id": "SDTM-154", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBDY must be numeric type in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBDY in LB must be numeric type." + } + } + }, + { + "core_id": "SDTM-155", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSDY must be numeric type in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VSDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VSDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VSDY in VS must be numeric type." + } + } + }, + { + "core_id": "SDTM-156", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXSTDY must be numeric type in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EXSTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXSTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXSTDY in EX must be numeric type." + } + } + }, + { + "core_id": "SDTM-157", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMSTDY must be numeric type in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "CMSTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_CMSTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "CMSTDY in CM must be numeric type." + } + } + }, + { + "core_id": "SDTM-158", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TA must contain required variables: STUDYID, DOMAIN, ARMCD, ARM, TAETORD, EPOCH, ELEMENT.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "ARMCD", + "ARM", + "TAETORD", + "EPOCH", + "ELEMENT" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_ARMCD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_ARM_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_TAETORD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_EPOCH_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_ELEMENT_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TA is missing one or more required variables: STUDYID, DOMAIN, ARMCD, ARM, TAETORD, EPOCH, ELEMENT." + } + } + }, + { + "core_id": "SDTM-159", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TE must contain required variables: STUDYID, DOMAIN, ETCD, ELEMENT.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TE" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "ETCD", + "ELEMENT" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_ETCD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_ELEMENT_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TE is missing one or more required variables: STUDYID, DOMAIN, ETCD, ELEMENT." + } + } + }, + { + "core_id": "SDTM-160", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TV must contain required variables: STUDYID, DOMAIN, VISITNUM, VISIT.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TV" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "VISITNUM", + "VISIT" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_VISITNUM_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_VISIT_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TV is missing one or more required variables: STUDYID, DOMAIN, VISITNUM, VISIT." + } + } + }, + { + "core_id": "SDTM-161", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TI must contain required variables: STUDYID, DOMAIN, IETESTCD, IETEST, IECAT, IEINCLCR.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "STUDYID", + "DOMAIN", + "IETESTCD", + "IETEST", + "IECAT", + "IEINCLCR" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_STUDYID_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_DOMAIN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_IETESTCD_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_IETEST_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_IECAT_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_IEINCLCR_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TI is missing one or more required variables: STUDYID, DOMAIN, IETESTCD, IETEST, IECAT, IEINCLCR." + } + } + }, + { + "core_id": "SDTM-162", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must be present in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "VISITNUM" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VISITNUM_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VISITNUM must be present in AE." + } + } + }, + { + "core_id": "SDTM-163", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must be present in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "VISITNUM" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VISITNUM_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VISITNUM must be present in VS." + } + } + }, + { + "core_id": "SDTM-164", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must be present in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "VISITNUM" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VISITNUM_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VISITNUM must be present in LB." + } + } + }, + { + "core_id": "SDTM-165", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must be present in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "VISITNUM" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VISITNUM_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VISITNUM must be present in EG." + } + } + }, + { + "core_id": "SDTM-166", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QVAL must not be null in SUPP-- supplemental qualifier datasets.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDM", + "SUPPAE", + "SUPPLB", + "SUPPVS", + "SUPPEG", + "SUPPCM", + "SUPPDS", + "SUPPMH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QVAL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QVAL must not be null in SUPP-- supplemental qualifier datasets." + } + } + }, + { + "core_id": "SDTM-167", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TSSEQ must not be null in TS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TSSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TSSEQ must not be null in TS." + } + } + }, + { + "core_id": "SDTM-168", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSTNRLO must be numeric type in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBSTNRLO", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSTNRLO_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBSTNRLO in LB must be numeric type." + } + } + }, + { + "core_id": "SDTM-169", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSTNRHI must be numeric type in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBSTNRHI", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSTNRHI_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBSTNRHI in LB must be numeric type." + } + } + }, + { + "core_id": "SDTM-170", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBORNRLO must be numeric type in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBORNRLO", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBORNRLO_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBORNRLO in LB must be numeric type." + } + } + }, + { + "core_id": "SDTM-171", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBORNRHI must be numeric type in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBORNRHI", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBORNRHI_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBORNRHI in LB must be numeric type." + } + } + }, + { + "core_id": "SDTM-172", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESEQ must be numeric type in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "AESEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AESEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "AESEQ in AE must be numeric type." + } + } + }, + { + "core_id": "SDTM-173", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXSEQ must be numeric type in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EXSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXSEQ in EX must be numeric type." + } + } + }, + { + "core_id": "SDTM-174", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMSEQ must be numeric type in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "CMSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_CMSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "CMSEQ in CM must be numeric type." + } + } + }, + { + "core_id": "SDTM-175", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHSEQ must be numeric type in MH.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "MHSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_MHSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "MHSEQ in MH must be numeric type." + } + } + }, + { + "core_id": "SDTM-176", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSSEQ must be numeric type in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VSSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VSSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VSSEQ in VS must be numeric type." + } + } + }, + { + "core_id": "SDTM-177", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSEQ must be numeric type in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBSEQ in LB must be numeric type." + } + } + }, + { + "core_id": "SDTM-178", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGSEQ must be numeric type in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EGSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EGSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EGSEQ in EG must be numeric type." + } + } + }, + { + "core_id": "SDTM-179", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PESEQ must be numeric type in PE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "PESEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_PESEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "PESEQ in PE must be numeric type." + } + } + }, + { + "core_id": "SDTM-180", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSSEQ must be numeric type in DS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "DSSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_DSSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "DSSEQ in DS must be numeric type." + } + } + }, + { + "core_id": "SDTM-181", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'DM' in the DM dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "DM" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'DM' in the DM dataset." + } + } + }, + { + "core_id": "SDTM-182", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'AE' in the AE dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "AE" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'AE' in the AE dataset." + } + } + }, + { + "core_id": "SDTM-183", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'LB' in the LB dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "LB" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'LB' in the LB dataset." + } + } + }, + { + "core_id": "SDTM-184", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'VS' in the VS dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "VS" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'VS' in the VS dataset." + } + } + }, + { + "core_id": "SDTM-185", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'EX' in the EX dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "EX" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'EX' in the EX dataset." + } + } + }, + { + "core_id": "SDTM-186", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'CM' in the CM dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "CM" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'CM' in the CM dataset." + } + } + }, + { + "core_id": "SDTM-187", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'DS' in the DS dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "DS" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'DS' in the DS dataset." + } + } + }, + { + "core_id": "SDTM-188", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'MH' in the MH dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "MH" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'MH' in the MH dataset." + } + } + }, + { + "core_id": "SDTM-189", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'EG' in the EG dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "EG" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'EG' in the EG dataset." + } + } + }, + { + "core_id": "SDTM-190", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'TA' in the TA dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "TA" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'TA' in the TA dataset." + } + } + }, + { + "core_id": "SDTM-191", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RFXSTDTC in DM must conform to ISO 8601 format when present (date of first exposure to trial treatment).", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "RFXSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "RFXSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_RFXSTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RFXSTDTC must conform to ISO 8601 format in DM." + } + } + }, + { + "core_id": "SDTM-192", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RFXENDTC in DM must conform to ISO 8601 format when present (date of last exposure to trial treatment).", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "RFXENDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "RFXENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_RFXENDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RFXENDTC must conform to ISO 8601 format in DM." + } + } + }, + { + "core_id": "SDTM-193", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RFICDTC in DM must conform to ISO 8601 format when present (date of informed consent).", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "RFICDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "RFICDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_RFICDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RFICDTC must conform to ISO 8601 format in DM." + } + } + }, + { + "core_id": "SDTM-194", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RFPENDTC in DM must conform to ISO 8601 format when present (date of end of participation).", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "RFPENDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "RFPENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_RFPENDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RFPENDTC must conform to ISO 8601 format in DM." + } + } + }, + { + "core_id": "SDTM-195", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AGE must not be negative in the DM dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "AGE", + "operator": "is_not_null", + "value": null + }, + { + "name": "AGE", + "operator": "less_than", + "value": 0 + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AGE must not be negative in DM." + } + } + }, + { + "core_id": "SDTM-196", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DMSEQ must not be null in the DM dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DMSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DMSEQ must not be null in DM." + } + } + }, + { + "core_id": "SDTM-197", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESCONG (congenital anomaly flag) must use the NY codelist when present in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AESCONG", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "AESCONG", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AESCONG_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESCONG must contain a value from the NY codelist in AE." + } + } + }, + { + "core_id": "SDTM-198", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESDISAB (disability flag) must use the NY codelist when present in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AESDISAB", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "AESDISAB", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AESDISAB_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESDISAB must contain a value from the NY codelist in AE." + } + } + }, + { + "core_id": "SDTM-199", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESMIE (important medical event flag) must use the NY codelist when present in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AESMIE", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "AESMIE", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AESMIE_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESMIE must contain a value from the NY codelist in AE." + } + } + }, + { + "core_id": "SDTM-200", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AECONTRT (concomitant treatment given flag) must use the NY codelist when present in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "AECONTRT", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "AECONTRT", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AECONTRT_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AECONTRT must contain a value from the NY codelist in AE." + } + } + }, + { + "core_id": "SDTM-201", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHDECOD (dictionary-derived term) must not be null in MH.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "MHDECOD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "MHDECOD must not be null in MH." + } + } + }, + { + "core_id": "SDTM-202", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHPRESP (pre-specified) must use the NY codelist when present in MH.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "MHPRESP", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "MHPRESP", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_MHPRESP_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "MHPRESP must contain a value from the NY codelist in MH." + } + } + }, + { + "core_id": "SDTM-203", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHOCCUR (occurrence) must use the NY codelist when present in MH.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "MHOCCUR", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "MHOCCUR", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_MHOCCUR_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "MHOCCUR must contain a value from the NY codelist in MH." + } + } + }, + { + "core_id": "SDTM-204", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXOCCUR (occurrence) must use the NY codelist when present in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "EXOCCUR", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "EXOCCUR", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EXOCCUR_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXOCCUR must contain a value from the NY codelist in EX." + } + } + }, + { + "core_id": "SDTM-205", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXDOSTOT (total cumulative dose) must be a numeric variable in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EXDOSTOT", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXDOSTOT_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXDOSTOT must be a numeric variable in EX." + } + } + }, + { + "core_id": "SDTM-206", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXDOSNO (dose number in a sequence) must be a numeric variable in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EXDOSNO", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXDOSNO_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXDOSNO must be a numeric variable in EX." + } + } + }, + { + "core_id": "SDTM-207", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMOCCUR (occurrence) must use the NY codelist when present in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "CMOCCUR", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "CMOCCUR", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_CMOCCUR_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMOCCUR must contain a value from the NY codelist in CM." + } + } + }, + { + "core_id": "SDTM-208", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMDOSE (dose per administration) must be a numeric variable in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "CMDOSE", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_CMDOSE_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "CMDOSE must be a numeric variable in CM." + } + } + }, + { + "core_id": "SDTM-209", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSCAT (category) must not be null in the DS dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DSCAT", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DSCAT must not be null in DS." + } + } + }, + { + "core_id": "SDTM-210", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSSTDY (study day of disposition event) must be a numeric variable in DS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "DSSTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_DSSTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "DSSTDY must be a numeric variable in DS." + } + } + }, + { + "core_id": "SDTM-211", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PETESTCD (test short name) must not be null in the PE dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "PETESTCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "PETESTCD must not be null in PE." + } + } + }, + { + "core_id": "SDTM-212", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PESTRESN (numeric result in standard units) must be a numeric variable in PE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "PESTRESN", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_PESTRESN_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "PESTRESN must be a numeric variable in PE." + } + } + }, + { + "core_id": "SDTM-213", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IEORRES (original result) must not be null in the IE dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "IE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IEORRES", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IEORRES must not be null in IE." + } + } + }, + { + "core_id": "SDTM-214", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IEDTC (date/time of IE assessment) must conform to ISO 8601 format when present in IE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "IE" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "IEDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "IEDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_IEDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IEDTC must conform to ISO 8601 format in IE." + } + } + }, + { + "core_id": "SDTM-215", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "SVSTDY (study day of visit start) must be a numeric variable in SV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "SVSTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_SVSTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "SVSTDY must be a numeric variable in SV." + } + } + }, + { + "core_id": "SDTM-216", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "SVENDY (study day of visit end) must be a numeric variable in SV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "SVENDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_SVENDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "SVENDY must be a numeric variable in SV." + } + } + }, + { + "core_id": "SDTM-217", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TSVAL (parameter value) must not be null in the TS dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TSVAL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TSVAL must not be null in TS." + } + } + }, + { + "core_id": "SDTM-218", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TSSEQ (sequence number) must be a numeric variable in TS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "TSSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_TSSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TSSEQ must be a numeric variable in TS." + } + } + }, + { + "core_id": "SDTM-219", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBFAST (fasting status) must use the NY codelist when present in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "LBFAST", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "LBFAST", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_LBFAST_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBFAST must contain a value from the NY codelist in LB." + } + } + }, + { + "core_id": "SDTM-220", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBCLSIG (clinically significant) must use the NY codelist when present in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "LBCLSIG", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "LBCLSIG", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_LBCLSIG_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBCLSIG must contain a value from the NY codelist in LB." + } + } + }, + { + "core_id": "SDTM-221", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBLLOQ (lower limit of quantification) must be a numeric variable in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBLLOQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBLLOQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBLLOQ must be a numeric variable in LB." + } + } + }, + { + "core_id": "SDTM-222", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TESTRL (trial element start rule) must not be null in the TE dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TESTRL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TESTRL must not be null in TE." + } + } + }, + { + "core_id": "SDTM-223", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TESTRL (trial element start rule) must be present as a variable in the TE dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TE" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "TESTRL" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_TESTRL_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TESTRL must be present in TE." + } + } + }, + { + "core_id": "SDTM-224", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TVSTRL (trial visit start rule) must not be null in the TV dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TVSTRL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TVSTRL must not be null in TV." + } + } + }, + { + "core_id": "SDTM-225", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must be a numeric variable in TV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TV" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VISITNUM", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VISITNUM_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VISITNUM must be a numeric variable in TV." + } + } + }, + { + "core_id": "SDTM-226", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TAETORD (order of element within arm) must be a numeric variable in TA.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "TAETORD", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_TAETORD_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TAETORD must be a numeric variable in TA." + } + } + }, + { + "core_id": "SDTM-227", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "ETCD (element code) must not be null in the TA dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "ETCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "ETCD must not be null in TA." + } + } + }, + { + "core_id": "SDTM-228", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'IE' in the IE dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "IE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "IE" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'IE' in the IE dataset." + } + } + }, + { + "core_id": "SDTM-229", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'SV' in the SV dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "SV" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'SV' in the SV dataset." + } + } + }, + { + "core_id": "SDTM-230", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'PE' in the PE dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "PE" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'PE' in the PE dataset." + } + } + }, + { + "core_id": "SDTM-231", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'TS' in the TS dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "TS" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'TS' in the TS dataset." + } + } + }, + { + "core_id": "SDTM-232", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'TE' in the TE dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "TE" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'TE' in the TE dataset." + } + } + }, + { + "core_id": "SDTM-233", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EG domain must contain required result variables: EGORRES, EGSTRESC, EGSTRESN, EGSTRESU.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "EGORRES", + "EGSTRESC", + "EGSTRESN", + "EGSTRESU" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_EGORRES_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_EGSTRESC_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_EGSTRESN_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_EGSTRESU_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EG domain is missing one or more required result variables (EGORRES, EGSTRESC, EGSTRESN, EGSTRESU)." + } + } + }, + { + "core_id": "SDTM-234", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PE domain must contain required result variables: PESTRESC, PESTRESN.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [ + { + "operator": "has_required_variables", + "params": { + "variables": [ + "PESTRESC", + "PESTRESN" + ] + } + } + ], + "conditions": { + "any": [ + { + "name": "_pb_PESTRESC_present", + "operator": "equal_to", + "value": false + }, + { + "name": "_pb_PESTRESN_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "PE domain is missing one or more required result variables (PESTRESC, PESTRESN)." + } + } + }, + { + "core_id": "SDTM-235", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESTDTC (start date/time of adverse event) must be present as a variable in the AE dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "AESTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AESTDTC_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "AESTDTC must be present in AE." + } + } + }, + { + "core_id": "SDTM-236", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXDOSE (dose per administration) must be present as a variable in the EX dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "EXDOSE" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXDOSE_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXDOSE must be present in EX." + } + } + }, + { + "core_id": "SDTM-237", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHSTDTC (start date/time of medical history event) must be present as a variable in the MH dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "MHSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_MHSTDTC_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "MHSTDTC must be present in MH." + } + } + }, + { + "core_id": "SDTM-238", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'TV' in the TV dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "TV" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'TV' in the TV dataset." + } + } + }, + { + "core_id": "SDTM-239", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DOMAIN column value must equal 'TI' in the TI dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "TI" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'TI' in the TI dataset." + } + } + }, + { + "core_id": "SDTM-240", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IEINCLCR (inclusion/exclusion criterion result flag) must be present as a variable in the TI dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "IEINCLCR" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_IEINCLCR_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "IEINCLCR must be present in TI." + } + } + }, + { + "core_id": "SDTM-241", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QNAM must not be null in SUPPDM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QNAM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QNAM must not be null in SUPPDM." + } + } + }, + { + "core_id": "SDTM-242", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QVAL must not be null in SUPPDM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QVAL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QVAL must not be null in SUPPDM." + } + } + }, + { + "core_id": "SDTM-243", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QNAM must not be null in SUPPAE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPAE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QNAM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QNAM must not be null in SUPPAE." + } + } + }, + { + "core_id": "SDTM-244", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QVAL must not be null in SUPPAE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPAE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QVAL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QVAL must not be null in SUPPAE." + } + } + }, + { + "core_id": "SDTM-245", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QNAM must not be null in SUPPLB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPLB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QNAM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QNAM must not be null in SUPPLB." + } + } + }, + { + "core_id": "SDTM-246", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RDOMAIN in SUPPDM must equal 'DM'.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "RDOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "RDOMAIN", + "operator": "not_equal_to", + "value": "DM" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RDOMAIN must be 'DM' in the SUPPDM dataset." + } + } + }, + { + "core_id": "SDTM-247", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RDOMAIN in SUPPAE must equal 'AE'.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPAE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "RDOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "RDOMAIN", + "operator": "not_equal_to", + "value": "AE" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RDOMAIN must be 'AE' in the SUPPAE dataset." + } + } + }, + { + "core_id": "SDTM-248", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RDOMAIN in SUPPLB must equal 'LB'.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPLB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "RDOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "RDOMAIN", + "operator": "not_equal_to", + "value": "LB" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RDOMAIN must be 'LB' in the SUPPLB dataset." + } + } + }, + { + "core_id": "SDTM-249", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RDOMAIN in SUPPVS must equal 'VS'.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPVS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "RDOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "RDOMAIN", + "operator": "not_equal_to", + "value": "VS" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RDOMAIN must be 'VS' in the SUPPVS dataset." + } + } + }, + { + "core_id": "SDTM-250", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QNAM must not be null in SUPPVS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPVS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QNAM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QNAM must not be null in SUPPVS." + } + } + }, + { + "core_id": "SDTM-251", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESTDTC in AE must conform to ISO 8601 format when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "AESTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "AESTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AESTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESTDTC must conform to ISO 8601 format in AE." + } + } + }, + { + "core_id": "SDTM-252", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AEENDTC in AE must conform to ISO 8601 format when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "AEENDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "AEENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AEENDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AEENDTC must conform to ISO 8601 format in AE." + } + } + }, + { + "core_id": "SDTM-253", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMSTDTC in CM must conform to ISO 8601 format when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "CMSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "CMSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_CMSTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMSTDTC must conform to ISO 8601 format in CM." + } + } + }, + { + "core_id": "SDTM-254", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMENDTC in CM must conform to ISO 8601 format when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "CMENDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "CMENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_CMENDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMENDTC must conform to ISO 8601 format in CM." + } + } + }, + { + "core_id": "SDTM-255", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXSTDTC in EX must conform to ISO 8601 format when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "EXSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "EXSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EXSTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXSTDTC must conform to ISO 8601 format in EX." + } + } + }, + { + "core_id": "SDTM-256", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXENDTC in EX must conform to ISO 8601 format when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "EXENDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "EXENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EXENDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXENDTC must conform to ISO 8601 format in EX." + } + } + }, + { + "core_id": "SDTM-257", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHSTDTC in MH must conform to ISO 8601 format when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "MHSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "MHSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_MHSTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "MHSTDTC must conform to ISO 8601 format in MH." + } + } + }, + { + "core_id": "SDTM-258", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHENDTC in MH must conform to ISO 8601 format when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "MHENDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "MHENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_MHENDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "MHENDTC must conform to ISO 8601 format in MH." + } + } + }, + { + "core_id": "SDTM-259", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSSTDTC in DS must conform to ISO 8601 format when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "DSSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "DSSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_DSSTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DSSTDTC must conform to ISO 8601 format in DS." + } + } + }, + { + "core_id": "SDTM-260", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DTHDTC in DM must conform to ISO 8601 format when present.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "DTHDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "DTHDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_DTHDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DTHDTC must conform to ISO 8601 format in DM." + } + } + }, + { + "core_id": "SDTM-261", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSTESTCD (test short name) must not be null in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VSTESTCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSTESTCD must not be null in VS." + } + } + }, + { + "core_id": "SDTM-262", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBTESTCD (test short name) must not be null in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "LBTESTCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBTESTCD must not be null in LB." + } + } + }, + { + "core_id": "SDTM-263", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGTESTCD (test short name) must not be null in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EGTESTCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EGTESTCD must not be null in EG." + } + } + }, + { + "core_id": "SDTM-264", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSTEST (test name) must not be null in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VSTEST", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSTEST must not be null in VS." + } + } + }, + { + "core_id": "SDTM-265", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBTEST (test name) must not be null in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "LBTEST", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBTEST must not be null in LB." + } + } + }, + { + "core_id": "SDTM-266", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSORRES (original result) must not be null in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VSORRES", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSORRES must not be null in VS." + } + } + }, + { + "core_id": "SDTM-267", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBORRES (original result) must not be null in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "LBORRES", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBORRES must not be null in LB." + } + } + }, + { + "core_id": "SDTM-268", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGORRES (original result) must not be null in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EGORRES", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EGORRES must not be null in EG." + } + } + }, + { + "core_id": "SDTM-269", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PEORRES (original result) must not be null in PE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "PEORRES", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "PEORRES must not be null in PE." + } + } + }, + { + "core_id": "SDTM-270", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must be a numeric variable in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VISITNUM", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VISITNUM_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VISITNUM must be a numeric variable in LB." + } + } + }, + { + "core_id": "SDTM-271", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESEQ (sequence number) must not be null in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "AESEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESEQ must not be null in AE." + } + } + }, + { + "core_id": "SDTM-272", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMSEQ (sequence number) must not be null in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "CMSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMSEQ must not be null in CM." + } + } + }, + { + "core_id": "SDTM-273", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXSEQ (sequence number) must not be null in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EXSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXSEQ must not be null in EX." + } + } + }, + { + "core_id": "SDTM-274", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHSEQ (sequence number) must not be null in MH.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "MHSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "MHSEQ must not be null in MH." + } + } + }, + { + "core_id": "SDTM-275", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSSEQ (sequence number) must not be null in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VSSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSSEQ must not be null in VS." + } + } + }, + { + "core_id": "SDTM-276", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSEQ (sequence number) must not be null in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "LBSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBSEQ must not be null in LB." + } + } + }, + { + "core_id": "SDTM-277", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGSEQ (sequence number) must not be null in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EGSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EGSEQ must not be null in EG." + } + } + }, + { + "core_id": "SDTM-278", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PESEQ (sequence number) must not be null in PE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "PESEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "PESEQ must not be null in PE." + } + } + }, + { + "core_id": "SDTM-279", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSSEQ (sequence number) must not be null in DS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DSSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DSSEQ must not be null in DS." + } + } + }, + { + "core_id": "SDTM-280", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IESEQ (sequence number) must not be null in IE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "IE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IESEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IESEQ must not be null in IE." + } + } + }, + { + "core_id": "SDTM-281", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSTNRLO (lower limit of reference range) must be a numeric variable in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBSTNRLO", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSTNRLO_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBSTNRLO must be a numeric variable in LB." + } + } + }, + { + "core_id": "SDTM-282", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSTNRHI (upper limit of reference range) must be a numeric variable in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBSTNRHI", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSTNRHI_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBSTNRHI must be a numeric variable in LB." + } + } + }, + { + "core_id": "SDTM-283", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSSTRESN (numeric result in standard units) must be a numeric variable in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VSSTRESN", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VSSTRESN_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VSSTRESN must be a numeric variable in VS." + } + } + }, + { + "core_id": "SDTM-284", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBNRIND (reference range indicator) must use values from the NRIND controlled terminology codelist when present in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "LBNRIND", + "codelist": "NRIND" + } + } + ], + "conditions": { + "all": [ + { + "name": "LBNRIND", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_LBNRIND_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBNRIND must use values from the NRIND codelist in LB." + } + } + }, + { + "core_id": "SDTM-285", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSNRIND (reference range indicator) must use values from the NRIND controlled terminology codelist when present in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "VSNRIND", + "codelist": "NRIND" + } + } + ], + "conditions": { + "all": [ + { + "name": "VSNRIND", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_VSNRIND_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSNRIND must use values from the NRIND codelist in VS." + } + } + }, + { + "core_id": "SDTM-286", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGNRIND (reference range indicator) must use values from the NRIND controlled terminology codelist when present in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "EGNRIND", + "codelist": "NRIND" + } + } + ], + "conditions": { + "all": [ + { + "name": "EGNRIND", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EGNRIND_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EGNRIND must use values from the NRIND codelist in EG." + } + } + }, + { + "core_id": "SDTM-287", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PENRIND (reference range indicator) must use values from the NRIND controlled terminology codelist when present in PE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "PENRIND", + "codelist": "NRIND" + } + } + ], + "conditions": { + "all": [ + { + "name": "PENRIND", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_PENRIND_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "PENRIND must use values from the NRIND codelist in PE." + } + } + }, + { + "core_id": "SDTM-288", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must be a numeric variable in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VISITNUM", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VISITNUM_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VISITNUM must be a numeric variable in EG." + } + } + }, + { + "core_id": "SDTM-289", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESER (serious event flag) must be present in the AE dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "AESER" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AESER_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "AESER must be present in AE." + } + } + }, + { + "core_id": "SDTM-290", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESEQ must be a numeric variable in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "AESEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AESEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "AESEQ must be a numeric variable in AE." + } + } + }, + { + "core_id": "SDTM-291", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSSEQ must be a numeric variable in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VSSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VSSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VSSEQ must be a numeric variable in VS." + } + } + }, + { + "core_id": "SDTM-292", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSEQ must be a numeric variable in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBSEQ must be a numeric variable in LB." + } + } + }, + { + "core_id": "SDTM-293", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGSEQ must be a numeric variable in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EGSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EGSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EGSEQ must be a numeric variable in EG." + } + } + }, + { + "core_id": "SDTM-294", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMSEQ must be a numeric variable in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "CMSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_CMSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "CMSEQ must be a numeric variable in CM." + } + } + }, + { + "core_id": "SDTM-295", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSSEQ must be a numeric variable in DS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "DSSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_DSSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "DSSEQ must be a numeric variable in DS." + } + } + }, + { + "core_id": "SDTM-296", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXSEQ must be a numeric variable in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EXSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXSEQ must be a numeric variable in EX." + } + } + }, + { + "core_id": "SDTM-297", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "SVSEQ must be a numeric variable in SV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "SVSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_SVSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "SVSEQ must be a numeric variable in SV." + } + } + }, + { + "core_id": "SDTM-298", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHSEQ must be a numeric variable in MH.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "MHSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_MHSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "MHSEQ must be a numeric variable in MH." + } + } + }, + { + "core_id": "SDTM-299", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QVAL must not be null in SUPPVS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPVS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QVAL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QVAL must not be null in SUPPVS." + } + } + }, + { + "core_id": "SDTM-300", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PETEST (physical exam test name) must not be null in PE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "PETEST", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "PETEST must not be null in PE." + } + } + }, + { + "core_id": "SDTM-301", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AETERM (verbatim adverse event term) must not be null in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "AETERM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AETERM must not be null in AE." + } + } + }, + { + "core_id": "SDTM-302", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AEDECOD (dictionary-derived preferred term) must not be null in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "AEDECOD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AEDECOD must not be null in AE." + } + } + }, + { + "core_id": "SDTM-303", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AEBODSYS (body system or organ class) must not be null in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "AEBODSYS", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AEBODSYS must not be null in AE." + } + } + }, + { + "core_id": "SDTM-304", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMTRT (verbatim medication name) must not be null in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "CMTRT", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMTRT must not be null in CM." + } + } + }, + { + "core_id": "SDTM-305", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMDECOD (standardized medication name) must not be null in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "CMDECOD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMDECOD must not be null in CM." + } + } + }, + { + "core_id": "SDTM-306", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSTERM (verbatim disposition term) must not be null in DS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DSTERM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DSTERM must not be null in DS." + } + } + }, + { + "core_id": "SDTM-307", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "DSDECOD (dictionary-derived disposition term) must not be null in DS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DSDECOD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DSDECOD must not be null in DS." + } + } + }, + { + "core_id": "SDTM-308", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHTERM (verbatim medical history term) must not be null in MH.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "MHTERM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "MHTERM must not be null in MH." + } + } + }, + { + "core_id": "SDTM-309", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXTRT (treatment name administered) must not be null in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EXTRT", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXTRT must not be null in EX." + } + } + }, + { + "core_id": "SDTM-310", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXDOSE (dose per administration) must not be null in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EXDOSE", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXDOSE must not be null in EX." + } + } + }, + { + "core_id": "SDTM-311", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSSTRESC (standardized result in character format) must not be null in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VSSTRESC", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSSTRESC must not be null in VS." + } + } + }, + { + "core_id": "SDTM-312", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSTRESC (standardized result in character format) must not be null in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "LBSTRESC", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBSTRESC must not be null in LB." + } + } + }, + { + "core_id": "SDTM-313", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGSTRESC (standardized result in character format) must not be null in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EGSTRESC", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EGSTRESC must not be null in EG." + } + } + }, + { + "core_id": "SDTM-314", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "PESTRESC (standardized result in character format) must not be null in PE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "PESTRESC", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "PESTRESC must not be null in PE." + } + } + }, + { + "core_id": "SDTM-315", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSSTRESU column must be present in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "VSSTRESU" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VSSTRESU_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VSSTRESU must be present in VS." + } + } + }, + { + "core_id": "SDTM-316", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSTRESU column must be present in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "LBSTRESU" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSTRESU_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBSTRESU must be present in LB." + } + } + }, + { + "core_id": "SDTM-317", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGSTRESU column must be present in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "EGSTRESU" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EGSTRESU_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EGSTRESU must be present in EG." + } + } + }, + { + "core_id": "SDTM-318", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSTRESN must be numeric type in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBSTRESN", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSTRESN_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBSTRESN must be numeric in LB." + } + } + }, + { + "core_id": "SDTM-319", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGSTRESN must be numeric type in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EGSTRESN", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EGSTRESN_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EGSTRESN must be numeric in EG." + } + } + }, + { + "core_id": "SDTM-320", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXDOSE must be numeric type in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EXDOSE", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXDOSE_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXDOSE must be numeric in EX." + } + } + }, + { + "core_id": "SDTM-321", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "ARM (arm name) must not be null in TA.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "ARM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "ARM must not be null in TA." + } + } + }, + { + "core_id": "SDTM-322", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EPOCH (epoch name) must not be null in TA.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EPOCH", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EPOCH must not be null in TA." + } + } + }, + { + "core_id": "SDTM-323", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "ELEMENT (trial element name) must not be null in TA.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "ELEMENT", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "ELEMENT must not be null in TA." + } + } + }, + { + "core_id": "SDTM-324", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TEENRL (trial element end rule) must not be null in TE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TEENRL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TEENRL must not be null in TE." + } + } + }, + { + "core_id": "SDTM-325", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISIT (visit description) must not be null in TV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VISIT", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VISIT must not be null in TV." + } + } + }, + { + "core_id": "SDTM-326", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "ARM (arm name) must not be null in TV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "ARM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "ARM must not be null in TV." + } + } + }, + { + "core_id": "SDTM-327", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "ARMCD (arm code) must not be null in TV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "ARMCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "ARMCD must not be null in TV." + } + } + }, + { + "core_id": "SDTM-328", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IETEST (inclusion/exclusion criterion name) must not be null in TI.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IETEST", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IETEST must not be null in TI." + } + } + }, + { + "core_id": "SDTM-329", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "IETESTCD (criterion short name) must not be null in TI.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Special-Purpose" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IETESTCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IETESTCD must not be null in TI." + } + } + }, + { + "core_id": "SDTM-330", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "TSPARMCD (trial summary parameter short name) must not be null in TS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "TS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TSPARMCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TSPARMCD must not be null in TS." + } + } + }, + { + "core_id": "SDTM-331", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must not be null in SV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VISITNUM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VISITNUM must not be null in SV." + } + } + }, + { + "core_id": "SDTM-332", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISIT must not be null in SV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VISIT", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VISIT must not be null in SV." + } + } + }, + { + "core_id": "SDTM-333", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "SVSTDTC must conform to ISO 8601 date/time format when present in SV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "SVSTDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "SVSTDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_SVSTDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "SVSTDTC must conform to ISO 8601 format in SV." + } + } + }, + { + "core_id": "SDTM-334", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "SVENDTC must conform to ISO 8601 date/time format when present in SV.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "SVENDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "SVENDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_SVENDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "SVENDTC must conform to ISO 8601 format in SV." + } + } + }, + { + "core_id": "SDTM-335", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must not be null in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VISITNUM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VISITNUM must not be null in VS." + } + } + }, + { + "core_id": "SDTM-336", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must not be null in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VISITNUM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VISITNUM must not be null in LB." + } + } + }, + { + "core_id": "SDTM-337", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must not be null in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VISITNUM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VISITNUM must not be null in EG." + } + } + }, + { + "core_id": "SDTM-338", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must not be null in PE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "VISITNUM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VISITNUM must not be null in PE." + } + } + }, + { + "core_id": "SDTM-339", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must be numeric type in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VISITNUM", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VISITNUM_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VISITNUM must be numeric in VS." + } + } + }, + { + "core_id": "SDTM-340", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VISITNUM must be numeric type in PE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VISITNUM", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VISITNUM_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VISITNUM must be numeric in PE." + } + } + }, + { + "core_id": "SDTM-341", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QLABEL must not be null in SUPPDM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QLABEL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QLABEL must not be null in SUPPDM." + } + } + }, + { + "core_id": "SDTM-342", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QLABEL must not be null in SUPPAE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPAE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QLABEL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QLABEL must not be null in SUPPAE." + } + } + }, + { + "core_id": "SDTM-343", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QLABEL must not be null in SUPPLB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPLB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QLABEL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QLABEL must not be null in SUPPLB." + } + } + }, + { + "core_id": "SDTM-344", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QNAM must not be null in SUPPEG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPEG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QNAM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QNAM must not be null in SUPPEG." + } + } + }, + { + "core_id": "SDTM-345", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QVAL must not be null in SUPPEG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPEG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QVAL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QVAL must not be null in SUPPEG." + } + } + }, + { + "core_id": "SDTM-346", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RDOMAIN must equal 'EG' in the SUPPEG dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPEG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "RDOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "RDOMAIN", + "operator": "not_equal_to", + "value": "EG" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RDOMAIN must be 'EG' in the SUPPEG dataset." + } + } + }, + { + "core_id": "SDTM-347", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QNAM must not be null in SUPPCM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPCM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QNAM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QNAM must not be null in SUPPCM." + } + } + }, + { + "core_id": "SDTM-348", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RDOMAIN must equal 'CM' in the SUPPCM dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPCM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "RDOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "RDOMAIN", + "operator": "not_equal_to", + "value": "CM" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RDOMAIN must be 'CM' in the SUPPCM dataset." + } + } + }, + { + "core_id": "SDTM-349", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QNAM must not be null in SUPPDS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QNAM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QNAM must not be null in SUPPDS." + } + } + }, + { + "core_id": "SDTM-350", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RDOMAIN must equal 'DS' in the SUPPDS dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "RDOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "RDOMAIN", + "operator": "not_equal_to", + "value": "DS" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RDOMAIN must be 'DS' in the SUPPDS dataset." + } + } + }, + { + "core_id": "SDTM-351", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "QNAM must not be null in SUPPMH.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPMH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QNAM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QNAM must not be null in SUPPMH." + } + } + }, + { + "core_id": "SDTM-352", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "RDOMAIN must equal 'MH' in the SUPPMH dataset.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPMH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "RDOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "RDOMAIN", + "operator": "not_equal_to", + "value": "MH" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "RDOMAIN must be 'MH' in the SUPPMH dataset." + } + } + }, + { + "core_id": "SDTM-353", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AESTDY (AE start study day) must be numeric type in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "AESTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AESTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "AESTDY must be numeric in AE." + } + } + }, + { + "core_id": "SDTM-354", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "AEENDY (AE end study day) must be numeric type in AE.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "AEENDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_AEENDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "AEENDY must be numeric in AE." + } + } + }, + { + "core_id": "SDTM-355", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "CMSTDY (CM start study day) must be numeric type in CM.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "CMSTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_CMSTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "CMSTDY must be numeric in CM." + } + } + }, + { + "core_id": "SDTM-356", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EXSTDY (EX start study day) must be numeric type in EX.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EXSTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXSTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXSTDY must be numeric in EX." + } + } + }, + { + "core_id": "SDTM-357", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "MHSTDY (MH start study day) must be numeric type in MH.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "MHSTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_MHSTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "MHSTDY must be numeric in MH." + } + } + }, + { + "core_id": "SDTM-358", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "VSSTDY (VS study day) must be numeric type in VS.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VSSTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VSSTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VSSTDY must be numeric in VS." + } + } + }, + { + "core_id": "SDTM-359", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "LBSTDY (LB study day) must be numeric type in LB.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBSTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBSTDY must be numeric in LB." + } + } + }, + { + "core_id": "SDTM-360", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "description": "EGSTDY (EG study day) must be numeric type in EG.", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EGSTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EGSTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EGSTDY must be numeric in EG." + } + } + }, + { + "core_id": "SDTM-361", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "VSDTC in VS must conform to ISO 8601 format when present.", + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "VSDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "VSDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_VSDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "VSDTC must be a valid ISO 8601 date/time in VS." + } + } + }, + { + "core_id": "SDTM-362", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "LBDTC in LB must conform to ISO 8601 format when present.", + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "LBDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "LBDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_LBDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBDTC must be a valid ISO 8601 date/time in LB." + } + } + }, + { + "core_id": "SDTM-363", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EGDTC in EG must conform to ISO 8601 format when present.", + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "EGDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "EGDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EGDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EGDTC must be a valid ISO 8601 date/time in EG." + } + } + }, + { + "core_id": "SDTM-364", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "PEDTC in PE must conform to ISO 8601 format when present.", + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "PEDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "PEDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_PEDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "PEDTC must be a valid ISO 8601 date/time in PE." + } + } + }, + { + "core_id": "SDTM-365", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "AEDTC in AE must conform to ISO 8601 format when present.", + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "AEDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "AEDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_AEDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AEDTC must be a valid ISO 8601 date/time in AE." + } + } + }, + { + "core_id": "SDTM-366", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "BRTHDTC in DM must conform to ISO 8601 format when present.", + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "iso8601_check", + "params": { + "column": "BRTHDTC" + } + } + ], + "conditions": { + "all": [ + { + "name": "BRTHDTC", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_BRTHDTC_iso8601", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "BRTHDTC must be a valid ISO 8601 date/time in DM." + } + } + }, + { + "core_id": "SDTM-367", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "DTHFL in DM must use the NY (No Yes) controlled terminology codelist when present.", + "classes": [ + "Special-Purpose" + ], + "domains": [ + "DM" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "DTHFL", + "codelist": "NY" + } + } + ], + "conditions": { + "all": [ + { + "name": "DTHFL", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_DTHFL_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DTHFL must be a valid NY codelist value (Y or N) in DM." + } + } + }, + { + "core_id": "SDTM-368", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EXENDY in EX must be a numeric type variable.", + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EXENDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXENDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXENDY must be numeric in EX." + } + } + }, + { + "core_id": "SDTM-369", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "CMENDY in CM must be a numeric type variable.", + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "CMENDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_CMENDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "CMENDY must be numeric in CM." + } + } + }, + { + "core_id": "SDTM-370", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "MHENDY in MH must be a numeric type variable.", + "classes": [ + "Events" + ], + "domains": [ + "MH" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "MHENDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_MHENDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "MHENDY must be numeric in MH." + } + } + }, + { + "core_id": "SDTM-371", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "LBSPEC (specimen type) column must be present in the LB domain.", + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "LBSPEC" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBSPEC_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBSPEC must be present in LB." + } + } + }, + { + "core_id": "SDTM-372", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "CMROUTE (route of administration) column must be present in the CM domain.", + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "CMROUTE" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_CMROUTE_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "CMROUTE must be present in CM." + } + } + }, + { + "core_id": "SDTM-373", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EXROUTE (route of administration) column must be present in the EX domain.", + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "EXROUTE" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXROUTE_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXROUTE must be present in EX." + } + } + }, + { + "core_id": "SDTM-374", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EXDOSFRM (dose form) column must be present in the EX domain.", + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "column_presence", + "params": { + "column": "EXDOSFRM" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EXDOSFRM_present", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EXDOSFRM must be present in EX." + } + } + }, + { + "core_id": "SDTM-375", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "LBSPEC must not be null in LB; specimen type is required for each lab result record.", + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "LBSPEC", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "LBSPEC must not be null in LB." + } + } + }, + { + "core_id": "SDTM-376", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "AESER must not be null in AE; the serious adverse event flag is required for each AE record.", + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "AESER", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESER must not be null in AE." + } + } + }, + { + "core_id": "SDTM-377", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "VSDY in VS must be a numeric type variable.", + "classes": [ + "Findings" + ], + "domains": [ + "VS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "VSDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_VSDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "VSDY must be numeric in VS." + } + } + }, + { + "core_id": "SDTM-378", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "LBDY in LB must be a numeric type variable.", + "classes": [ + "Findings" + ], + "domains": [ + "LB" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "LBDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_LBDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "LBDY must be numeric in LB." + } + } + }, + { + "core_id": "SDTM-379", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EGDY in EG must be a numeric type variable.", + "classes": [ + "Findings" + ], + "domains": [ + "EG" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "EGDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_EGDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "EGDY must be numeric in EG." + } + } + }, + { + "core_id": "SDTM-380", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "PEDY in PE must be a numeric type variable.", + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "PEDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_PEDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "PEDY must be numeric in PE." + } + } + }, + { + "core_id": "SDTM-381", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "QLABEL must not be null in SUPPVS; the qualifier label is required in all supplemental datasets.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPVS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QLABEL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QLABEL must not be null in SUPPVS." + } + } + }, + { + "core_id": "SDTM-382", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "QLABEL must not be null in SUPPEG; the qualifier label is required in all supplemental datasets.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPEG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QLABEL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QLABEL must not be null in SUPPEG." + } + } + }, + { + "core_id": "SDTM-383", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "QLABEL must not be null in SUPPCM; the qualifier label is required in all supplemental datasets.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPCM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QLABEL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QLABEL must not be null in SUPPCM." + } + } + }, + { + "core_id": "SDTM-384", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "QLABEL must not be null in SUPPDS; the qualifier label is required in all supplemental datasets.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QLABEL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QLABEL must not be null in SUPPDS." + } + } + }, + { + "core_id": "SDTM-385", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "QLABEL must not be null in SUPPMH; the qualifier label is required in all supplemental datasets.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPMH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "QLABEL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "QLABEL must not be null in SUPPMH." + } + } + }, + { + "core_id": "SDTM-386", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IDVAR must not be null in SUPPDM; the linking variable name is required to connect supplemental records to the parent dataset.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IDVAR", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IDVAR must not be null in SUPPDM." + } + } + }, + { + "core_id": "SDTM-387", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IDVARVAL must not be null in SUPPDM; the linking variable value is required to connect supplemental records to the parent dataset.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IDVARVAL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IDVARVAL must not be null in SUPPDM." + } + } + }, + { + "core_id": "SDTM-388", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IDVAR must not be null in SUPPAE; the linking variable name is required to connect supplemental records to the parent AE dataset.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPAE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IDVAR", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IDVAR must not be null in SUPPAE." + } + } + }, + { + "core_id": "SDTM-389", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IDVARVAL must not be null in SUPPAE; the linking variable value is required to connect supplemental records to the parent AE dataset.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPAE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IDVARVAL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IDVARVAL must not be null in SUPPAE." + } + } + }, + { + "core_id": "SDTM-390", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IDVAR must not be null in SUPPLB; the linking variable name is required to connect supplemental records to the parent LB dataset.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPLB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IDVAR", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IDVAR must not be null in SUPPLB." + } + } + }, + { + "core_id": "SDTM-391", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IDVARVAL must not be null in SUPPLB; the linking variable value is required to connect supplemental records to the parent LB dataset.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPLB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IDVARVAL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IDVARVAL must not be null in SUPPLB." + } + } + }, + { + "core_id": "SDTM-392", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IDVAR must not be null in SUPPVS; the linking variable name is required to connect supplemental records to the parent VS dataset.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPVS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IDVAR", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IDVAR must not be null in SUPPVS." + } + } + }, + { + "core_id": "SDTM-393", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IDVARVAL must not be null in SUPPVS; the linking variable value is required to connect supplemental records to the parent VS dataset.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPVS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IDVARVAL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IDVARVAL must not be null in SUPPVS." + } + } + }, + { + "core_id": "SDTM-394", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "TSPARM must not be null in TS; the parameter name is required for every trial summary record.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TSPARM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TSPARM must not be null in TS." + } + } + }, + { + "core_id": "SDTM-395", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IECAT must not be null in TI; the inclusion/exclusion category is required for each criterion record.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IECAT", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IECAT must not be null in TI." + } + } + }, + { + "core_id": "SDTM-396", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "ARMCD must not be null in TA; the arm code is required for every trial arm record.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "ARMCD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "ARMCD must not be null in TA." + } + } + }, + { + "core_id": "SDTM-397", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "TAETORD must not be null in TA; the planned element order within the arm is required.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TAETORD", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TAETORD must not be null in TA." + } + } + }, + { + "core_id": "SDTM-398", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "TVSEQ must not be null in TV; the sequence number is required for every trial visit record.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TVSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TVSEQ must not be null in TV." + } + } + }, + { + "core_id": "SDTM-399", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "TVSEQ in TV must be a numeric type variable.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TV" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "TVSEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_TVSEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TVSEQ must be numeric in TV." + } + } + }, + { + "core_id": "SDTM-400", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "TISEQ must not be null in TI; the sequence number is required for every trial inclusion/exclusion criteria record.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TISEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TISEQ must not be null in TI." + } + } + }, + { + "core_id": "SDTM-401", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "TISEQ in TI must be a numeric type variable.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TI" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "TISEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_TISEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TISEQ must be numeric in TI." + } + } + }, + { + "core_id": "SDTM-402", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "TASEQ must not be null in TA; the sequence number is required for every trial arm record.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TASEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TASEQ must not be null in TA." + } + } + }, + { + "core_id": "SDTM-403", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "TASEQ in TA must be a numeric type variable.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "TASEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_TASEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TASEQ must be numeric in TA." + } + } + }, + { + "core_id": "SDTM-404", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "TESEQ must not be null in TE; the sequence number is required for every trial element record.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "TESEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "TESEQ must not be null in TE." + } + } + }, + { + "core_id": "SDTM-405", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "TESEQ in TE must be a numeric type variable.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "TESEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_TESEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "TESEQ must be numeric in TE." + } + } + }, + { + "core_id": "SDTM-406", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "SVSEQ must not be null in SV; the sequence number is required for every subject visit record.", + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "SVSEQ", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "SVSEQ must not be null in SV." + } + } + }, + { + "core_id": "SDTM-407", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "PESEQ in PE must be a numeric type variable.", + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "PESEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_PESEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "PESEQ must be numeric in PE." + } + } + }, + { + "core_id": "SDTM-408", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IESEQ in IE must be a numeric type variable.", + "classes": [ + "Special-Purpose" + ], + "domains": [ + "IE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "IESEQ", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_IESEQ_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "IESEQ must be numeric in IE." + } + } + }, + { + "core_id": "SDTM-409", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "PEENDY in PE must be a numeric type variable.", + "classes": [ + "Findings" + ], + "domains": [ + "PE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "PEENDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_PEENDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "PEENDY must be numeric in PE." + } + } + }, + { + "core_id": "SDTM-410", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IESTDY in IE must be a numeric type variable.", + "classes": [ + "Special-Purpose" + ], + "domains": [ + "IE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "IESTDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_IESTDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "IESTDY must be numeric in IE." + } + } + }, + { + "core_id": "SDTM-411", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IEENDY in IE must be a numeric type variable.", + "classes": [ + "Special-Purpose" + ], + "domains": [ + "IE" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "IEENDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_IEENDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "IEENDY must be numeric in IE." + } + } + }, + { + "core_id": "SDTM-412", + "rule_type": "VARIABLE_METADATA_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "DSENDY in DS must be a numeric type variable.", + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [ + { + "operator": "variable_type_check", + "params": { + "column": "DSENDY", + "expected_type": "numeric" + } + } + ], + "conditions": { + "all": [ + { + "name": "_pb_DSENDY_type_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_variable_error", + "params": { + "message": "DSENDY must be numeric in DS." + } + } + }, + { + "core_id": "SDTM-413", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EXDOSFRM must not be null in EX; the dose form is required for each exposure record.", + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EXDOSFRM", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXDOSFRM must not be null in EX." + } + } + }, + { + "core_id": "SDTM-414", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EXROUTE must not be null in EX; the route of administration is required for each exposure record.", + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EXROUTE", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXROUTE must not be null in EX." + } + } + }, + { + "core_id": "SDTM-415", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "CMROUTE must not be null in CM; the route of administration is required for each concomitant medication record.", + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "CMROUTE", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMROUTE must not be null in CM." + } + } + }, + { + "core_id": "SDTM-416", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "AEACN must not be null in AE; the action taken with study treatment is required for each adverse event record.", + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "AEACN", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AEACN must not be null in AE." + } + } + }, + { + "core_id": "SDTM-417", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "AESTDTC must not be null in AE; the adverse event start date/time is required.", + "classes": [ + "Events" + ], + "domains": [ + "AE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "AESTDTC", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "AESTDTC must not be null in AE." + } + } + }, + { + "core_id": "SDTM-418", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "DSSTDTC must not be null in DS; the disposition date/time is required for each disposition record.", + "classes": [ + "Events" + ], + "domains": [ + "DS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DSSTDTC", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DSSTDTC must not be null in DS." + } + } + }, + { + "core_id": "SDTM-419", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EXSTDTC must not be null in EX; the exposure start date/time is required for each exposure record.", + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "EXSTDTC", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXSTDTC must not be null in EX." + } + } + }, + { + "core_id": "SDTM-420", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "SVSTDTC must not be null in SV; the subject visit start date/time is required for each subject visit record.", + "classes": [ + "Trial Design" + ], + "domains": [ + "SV" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "SVSTDTC", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "SVSTDTC must not be null in SV." + } + } + }, + { + "core_id": "SDTM-421", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "DOMAIN must equal 'SUPPDM' in the SUPPDM dataset; each record must carry the correct supplemental qualifier domain code.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "SUPPDM" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'SUPPDM' in the SUPPDM dataset." + } + } + }, + { + "core_id": "SDTM-422", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "DOMAIN must equal 'SUPPAE' in the SUPPAE dataset; each record must carry the correct supplemental qualifier domain code.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPAE" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "SUPPAE" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'SUPPAE' in the SUPPAE dataset." + } + } + }, + { + "core_id": "SDTM-423", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "DOMAIN must equal 'SUPPLB' in the SUPPLB dataset; each record must carry the correct supplemental qualifier domain code.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPLB" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "SUPPLB" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'SUPPLB' in the SUPPLB dataset." + } + } + }, + { + "core_id": "SDTM-424", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "DOMAIN must equal 'SUPPVS' in the SUPPVS dataset; each record must carry the correct supplemental qualifier domain code.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPVS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "SUPPVS" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'SUPPVS' in the SUPPVS dataset." + } + } + }, + { + "core_id": "SDTM-425", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "DOMAIN must equal 'SUPPEG' in the SUPPEG dataset; each record must carry the correct supplemental qualifier domain code.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPEG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "SUPPEG" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'SUPPEG' in the SUPPEG dataset." + } + } + }, + { + "core_id": "SDTM-426", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "DOMAIN must equal 'SUPPCM' in the SUPPCM dataset; each record must carry the correct supplemental qualifier domain code.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPCM" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "SUPPCM" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'SUPPCM' in the SUPPCM dataset." + } + } + }, + { + "core_id": "SDTM-427", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "DOMAIN must equal 'SUPPDS' in the SUPPDS dataset; each record must carry the correct supplemental qualifier domain code.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPDS" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "SUPPDS" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'SUPPDS' in the SUPPDS dataset." + } + } + }, + { + "core_id": "SDTM-428", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "DOMAIN must equal 'SUPPMH' in the SUPPMH dataset; each record must carry the correct supplemental qualifier domain code.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPMH" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "DOMAIN", + "operator": "is_not_null", + "value": null + }, + { + "name": "DOMAIN", + "operator": "not_equal_to", + "value": "SUPPMH" + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "DOMAIN value must be 'SUPPMH' in the SUPPMH dataset." + } + } + }, + { + "core_id": "SDTM-429", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IDVAR must not be null in SUPPEG; the identifier variable name is required to link each supplemental qualifier record back to its parent EG record.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPEG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IDVAR", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IDVAR must not be null in SUPPEG." + } + } + }, + { + "core_id": "SDTM-430", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "IDVARVAL must not be null in SUPPEG; the identifier variable value is required to link each supplemental qualifier record back to its parent EG record.", + "classes": [ + "Relationship" + ], + "domains": [ + "SUPPEG" + ], + "datasets": [], + "operations": [], + "conditions": { + "all": [ + { + "name": "IDVARVAL", + "operator": "is_null", + "value": null + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "IDVARVAL must not be null in SUPPEG." + } + } } ] } From 5f1781476cef2645d5b7e67c1003629eca2461eb Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Mon, 13 Jul 2026 23:59:05 -0400 Subject: [PATCH 59/93] Expand conformance fixture data and rule totals --- tests/test_native_conformance.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/test_native_conformance.py b/tests/test_native_conformance.py index 15b6e1f50..ce7b5a455 100644 --- a/tests/test_native_conformance.py +++ b/tests/test_native_conformance.py @@ -64,9 +64,27 @@ def engine(): return NativeConformanceEngine("sdtmig", "3.4") +def _clean_ta() -> pl.DataFrame: + return pl.DataFrame({ + "STUDYID": ["S001", "S001"], "DOMAIN": ["TA", "TA"], + "ARMCD": ["A", "B"], "ARM": ["Arm A", "Arm B"], + "TAETORD": [1, 1], "EPOCH": ["TREATMENT", "TREATMENT"], + "ELEMENT": ["Element 1", "Element 1"], "ETCD": ["ET1", "ET1"], + "TASEQ": [1, 2], + }) + + +def _clean_ts() -> pl.DataFrame: + return pl.DataFrame({ + "STUDYID": ["S001"], "DOMAIN": ["TS"], + "TSSEQ": [1], "TSPARMCD": ["PLANSUB"], + "TSPARM": ["Planned Number of Subjects"], "TSVAL": ["100"], + }) + + @pytest.fixture def clean_result(engine): - return engine.run({"DM": _clean_dm()}) + return engine.run({"DM": _clean_dm(), "TA": _clean_ta(), "TS": _clean_ts()}) # ── RuleLoader ──────────────────────────────────────────────────────────────── @@ -346,7 +364,7 @@ def test_engine_clean_dm_zero_issues(clean_result): def test_engine_rule_count(clean_result): - assert len(clean_result.rule_results) == 120 + assert len(clean_result.rule_results) == 430 def test_engine_result_types(clean_result): @@ -1090,4 +1108,4 @@ def test_engine_accepts_metadata_import_directly(): def test_engine_rule_count_phase3(): engine = NativeConformanceEngine("sdtmig", "3.4") result = engine.run({"DM": _clean_dm()}) - assert len(result.rule_results) == 120 + assert len(result.rule_results) == 430 From 483039ec36ca6e0188c1193f9b1bc6af33cee82d Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 01:03:40 -0400 Subject: [PATCH 60/93] Add missing SDTM CT codelists for conformance --- .../conformance/ct/sdtm-ct-2024-09-27.json | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json b/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json index 52472f285..fe0fb2e36 100644 --- a/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json +++ b/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json @@ -321,6 +321,62 @@ "NOT APPLICABLE", "UNKNOWN", "NOT REPORTED" + ], + "ROUTE": [ + "AURICULAR", "BUCCAL", "CUTANEOUS", "DENTAL", "ENDOCERVICAL", + "ENDOSINUSIAL", "ENDOTRACHEAL", "ENTERAL", "EPIDURAL", "EXTRA-AMNIOTIC", + "EXTRACORPOREAL", "HEMODIALYSIS", "INTRA-ARTICULAR", "INTRABRONCHIAL", + "INTRABURSAL", "INTRACARDIAC", "INTRACARTILAGINOUS", "INTRACAVERNOUS", + "INTRACEREBRAL", "INTRACERVICAL", "INTRACISTERNAL", "INTRACORNEAL", + "INTRADUCTAL", "INTRADUODENAL", "INTRADURAL", "INTRAEPIDERMAL", + "INTRAESOPHAGEAL", "INTRAGASTRIC", "INTRAGINGIVAL", "INTRAILEAL", + "INTRALESIONAL", "INTRALUMINAL", "INTRALYMPHATIC", "INTRAMEDULLARY", + "INTRAMUSCULAR", "INTRAOCULAR", "INTRAOVARIAN", "INTRAPERICARDIAL", + "INTRAPERITONEAL", "INTRAPLEURAL", "INTRAPROSTATIC", "INTRAPULMONARY", + "INTRASINAL", "INTRASPINAL", "INTRASYNOVIAL", "INTRATENDINOUS", + "INTRATESTICULAR", "INTRATHECAL", "INTRATHORACIC", "INTRATUBULAR", + "INTRATUMOR", "INTRATYMPANIC", "INTRAUTERINE", "INTRAVASCULAR", + "INTRAVENOUS", "INTRAVENOUS BOLUS", "INTRAVENOUS DRIP", "INTRAVESICAL", + "INTRAVITREAL", "IONTOPHORESIS", "IRRIGATION", "LARYNGEAL", "NASAL", + "NASOGASTRIC", "NOT APPLICABLE", "OCULAR", "OPHTHALMIC", "ORAL", + "OROPHARYNGEAL", "OTHER", "PARENTERAL", "PERIARTICULAR", "PERIDURAL", + "PERINEURAL", "PERIODONTAL", "RECTAL", "RESPIRATORY (INHALATION)", + "RETROBULBAR", "SOFT TISSUE", "SUBARACHNOID", "SUBCONJUNCTIVAL", + "SUBCUTANEOUS", "SUBGINGIVAL", "SUBLINGUAL", "SUBMUCOSAL", "TOPICAL", + "TRANSDERMAL", "TRANSLINGUAL", "TRANSMUCOSAL", "TRANSPLACENTAL", + "TRANSTRACHEAL", "TRANSTYMPANIC", "UNASSIGNED", "UNKNOWN", "URETHRAL", + "VAGINAL" + ], + "FRM": [ + "AEROSOL", "AEROSOL FOAM", "BAR SOAP", "BEAD", "CAPSULE", + "CAPSULE DELAYED RELEASE", "CAPSULE EXTENDED RELEASE", "CAPSULE ORAL", + "CAPSULE SOLUBLE", "CELL", "CLOTH", "CONCENTRATE", "CREAM", "CRYSTAL", + "DISK", "DRESSING", "DROPS", "ELIXIR", "EMULSION", "ENEMA", "FILM", + "FILM-COATED TABLET", "FOAM", "GEL", "GRANULES", "GUM", "IMPLANT", + "INFUSION", "INHALANT", "INJECTION", "INSERT", "INTRAUTERINE DEVICE", + "JELLY", "LINIMENT", "LIQUID", "LOTION", "LOZENGE", "OINTMENT", + "PATCH", "PELLET", "PESSARY", "PILL", "PLASTER", "POWDER", + "RECTAL OINTMENT", "SHAMPOO", "SOLUTION", "SPRAY", "SUPPOSITORY", + "SUSPENSION", "SYRUP", "TABLET", "TABLET BUCCAL", "TABLET CHEWABLE", + "TABLET COATED", "TABLET DELAYED RELEASE", "TABLET DISPERSIBLE", + "TABLET EFFERVESCENT", "TABLET EXTENDED RELEASE", "TABLET ORODISPERSIBLE", + "TABLET SOLUBLE", "TAPE", "TINCTURE", "TROCHE", "UNASSIGNED", + "UNKNOWN", "WAFER" + ], + "EPOCH": [ + "BASELINE", "DOUBLE-BLIND TREATMENT", "FOLLOW-UP", "INDUCTION", + "LEAD-IN", "MAINTENANCE", "OPEN-LABEL EXTENSION", "OPEN-LABEL TREATMENT", + "POST-DOSE", "POST-TREATMENT", "PRE-DOSE", "RANDOMIZATION", "RUN-IN", + "SCREENING", "SINGLE-BLIND TREATMENT", "TREATMENT", "UNBLINDED TREATMENT", + "WASHOUT" + ], + "FREQ": [ + "BID", "BID PRN", "CONT", "DAILY", "EVERY 2 HOURS", "EVERY 2 WEEKS", + "EVERY 24 HOURS", "EVERY 3 HOURS", "EVERY 3 WEEKS", "EVERY 4 HOURS", + "EVERY 4 WEEKS", "EVERY 48 HOURS", "EVERY 6 HOURS", "EVERY 8 HOURS", + "EVERY OTHER DAY", "MONTHLY", "ONCE", "ON DEMAND", "PRN", "Q15MIN", + "QD", "QHS", "QID", "QOD", "QW", "SEMI-MONTHLY", "SINGLE", "TID", + "TWICE A MONTH", "UNKNOWN", "WEEKLY" ] } } From cd259e2a3dd99f379e21818a812c920372a21510 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 01:03:59 -0400 Subject: [PATCH 61/93] Add SDTM codelist checks for CM, EX, and TA --- .../data/conformance/rules/sdtmig-3-4.json | 239 +++++++++++++++++- 1 file changed, 237 insertions(+), 2 deletions(-) diff --git a/pointblank/data/conformance/rules/sdtmig-3-4.json b/pointblank/data/conformance/rules/sdtmig-3-4.json index 8e32107ad..7517f0f16 100644 --- a/pointblank/data/conformance/rules/sdtmig-3-4.json +++ b/pointblank/data/conformance/rules/sdtmig-3-4.json @@ -1,9 +1,9 @@ { "standard": "sdtmig", "version": "3.4", - "generated": "2026-07-14T03:44:03Z", + "generated": "2026-07-14T00:00:00Z", "source": "CDISC SDTM Implementation Guide 3.4, hand-curated from public specification", - "checksum": "e060bbc7d0171f3a", + "checksum": "d46f73e9d2244fdb", "rules": [ { "core_id": "SDTM-001", @@ -17848,6 +17848,241 @@ "message": "IDVARVAL must not be null in SUPPEG." } } + }, + { + "core_id": "SDTM-431", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "CMROUTE in CM must be a valid ROUTE codelist term when present.", + "classes": [ + "Interventions" + ], + "domains": [ + "CM" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "CMROUTE", + "codelist": "ROUTE" + } + } + ], + "conditions": { + "all": [ + { + "name": "CMROUTE", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_CMROUTE_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "CMROUTE must be a valid CDISC ROUTE term when present." + } + } + }, + { + "core_id": "SDTM-432", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EXROUTE in EX must be a valid ROUTE codelist term when present.", + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "EXROUTE", + "codelist": "ROUTE" + } + } + ], + "conditions": { + "all": [ + { + "name": "EXROUTE", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EXROUTE_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXROUTE must be a valid CDISC ROUTE term when present." + } + } + }, + { + "core_id": "SDTM-433", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EXDOSFRM in EX must be a valid FRM codelist term when present.", + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "EXDOSFRM", + "codelist": "FRM" + } + } + ], + "conditions": { + "all": [ + { + "name": "EXDOSFRM", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EXDOSFRM_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXDOSFRM must be a valid CDISC FRM term when present." + } + } + }, + { + "core_id": "SDTM-434", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EPOCH in TA must be a valid EPOCH codelist term when present.", + "classes": [ + "Trial Design" + ], + "domains": [ + "TA" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "EPOCH", + "codelist": "EPOCH" + } + } + ], + "conditions": { + "all": [ + { + "name": "EPOCH", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EPOCH_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EPOCH must be a valid CDISC EPOCH term when present." + } + } + }, + { + "core_id": "SDTM-435", + "rule_type": "RECORD_CHECK", + "executability": "Fully Executable", + "sensitivity": "Error", + "authority": "CDISC", + "standards": [ + "sdtmig" + ], + "description": "EXDOSFRQ in EX must be a valid FREQ codelist term when present (dosing frequency per interval).", + "classes": [ + "Interventions" + ], + "domains": [ + "EX" + ], + "datasets": [], + "operations": [ + { + "operator": "codelist_check", + "params": { + "column": "EXDOSFRQ", + "codelist": "FREQ" + } + } + ], + "conditions": { + "all": [ + { + "name": "EXDOSFRQ", + "operator": "is_not_null", + "value": null + }, + { + "name": "_pb_EXDOSFRQ_valid", + "operator": "equal_to", + "value": false + } + ] + }, + "actions": { + "id": "generate_record_error", + "params": { + "message": "EXDOSFRQ must be a valid CDISC FREQ term when present." + } + } } ] } From 701b19eb2128512d6877d5bf0c10c0fe4791c261 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 01:04:19 -0400 Subject: [PATCH 62/93] Add CT codelist tests and update rule count --- tests/test_native_conformance.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/test_native_conformance.py b/tests/test_native_conformance.py index ce7b5a455..730a0e819 100644 --- a/tests/test_native_conformance.py +++ b/tests/test_native_conformance.py @@ -162,6 +162,30 @@ def test_ct_missing_package_raises(): ControlledTerminology.load(["no-such-package-2099-01-01"]) +def test_ct_route_codelist(ct): + assert ct.is_valid("ROUTE", "ORAL") + assert ct.is_valid("ROUTE", "INTRAVENOUS") + assert not ct.is_valid("ROUTE", "PURPLE") + + +def test_ct_frm_codelist(ct): + assert ct.is_valid("FRM", "TABLET") + assert ct.is_valid("FRM", "CAPSULE") + assert not ct.is_valid("FRM", "MYSTERY") + + +def test_ct_epoch_codelist(ct): + assert ct.is_valid("EPOCH", "SCREENING") + assert ct.is_valid("EPOCH", "TREATMENT") + assert not ct.is_valid("EPOCH", "PHASE 99") + + +def test_ct_freq_codelist(ct): + assert ct.is_valid("FREQ", "QD") + assert ct.is_valid("FREQ", "BID") + assert not ct.is_valid("FREQ", "WHENEVER") + + # ── Evaluator ───────────────────────────────────────────────────────────────── @@ -364,7 +388,7 @@ def test_engine_clean_dm_zero_issues(clean_result): def test_engine_rule_count(clean_result): - assert len(clean_result.rule_results) == 430 + assert len(clean_result.rule_results) == 435 def test_engine_result_types(clean_result): @@ -1108,4 +1132,4 @@ def test_engine_accepts_metadata_import_directly(): def test_engine_rule_count_phase3(): engine = NativeConformanceEngine("sdtmig", "3.4") result = engine.run({"DM": _clean_dm()}) - assert len(result.rule_results) == 430 + assert len(result.rule_results) == 435 From d74d9009e3759ff174e1687bbf3d90df335c6e06 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 12:12:46 -0400 Subject: [PATCH 63/93] Clean TYPE_CHECKING imports in submission --- pointblank/metadata/_submission.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pointblank/metadata/_submission.py b/pointblank/metadata/_submission.py index 55d169162..6c2ff377e 100644 --- a/pointblank/metadata/_submission.py +++ b/pointblank/metadata/_submission.py @@ -13,13 +13,14 @@ from __future__ import annotations -from dataclasses import dataclass, field as dataclass_field +from dataclasses import dataclass +from dataclasses import field as dataclass_field from pathlib import Path from typing import TYPE_CHECKING, Any, Sequence if TYPE_CHECKING: - from pointblank.metadata._cdisc_core import CoreFinding, CoreRuleResult, ParsedCoreReport - from pointblank.metadata._conformance.result import NativeConformanceResult, NativeRowFinding, NativeRuleResult + from pointblank.metadata._cdisc_core import ParsedCoreReport + from pointblank.metadata._conformance.result import NativeConformanceResult from pointblank.metadata._types import MetadataPackage from pointblank.validate import Validate From b926ac7946fce9a5072979ade7110054439a4a77 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 12:13:03 -0400 Subject: [PATCH 64/93] Add GT tabular report for native conformance --- pointblank/metadata/_submission.py | 244 ++++++++++++++++++++++++----- 1 file changed, 201 insertions(+), 43 deletions(-) diff --git a/pointblank/metadata/_submission.py b/pointblank/metadata/_submission.py index 6c2ff377e..7fc709ce6 100644 --- a/pointblank/metadata/_submission.py +++ b/pointblank/metadata/_submission.py @@ -45,7 +45,7 @@ def _is_relrec(name: str) -> bool: def _is_adam(name: str) -> bool: - """Whether a dataset name is an ADaM dataset (conventionally prefixed ``AD``).""" + """Whether a dataset name is an ADaM dataset (conventionally prefixed `AD`).""" return name.upper().startswith("AD") @@ -86,13 +86,13 @@ def _read_xpt_data(path: Path) -> Any: def _read_dataset_json(path: Path) -> tuple[Any, str | None]: """Read a CDISC Dataset-JSON file into a pandas DataFrame. - Supports both the Dataset-JSON 1.1 top-level ``columns``/``rows`` layout and the older - ``clinicalData``/``referenceData`` → ``itemGroupData`` nesting. + Supports both the Dataset-JSON 1.1 top-level `columns`/`rows` layout and the older + `clinicalData`/`referenceData` -> `itemGroupData` nesting. Returns ------- tuple[Any, str | None] - The DataFrame and the dataset name (domain) if discoverable, else ``None``. + The DataFrame and the dataset name (domain) if discoverable, else `None`. """ import json @@ -502,7 +502,10 @@ def validate_conformance( std = standard or self.standard ver = version or self.standard_version rules_report = self._run_rules_conformance( - agency=agency, standard=std, version=ver, ct_packages=ct_packages, + agency=agency, + standard=std, + version=ver, + ct_packages=ct_packages, define_xml=define_xml, ) if rules_report is not None: @@ -542,8 +545,10 @@ def _run_rules_conformance( standard=standard, version=version, ct_packages=ct_packages ) # Prefer explicit define_xml argument; fall back to package's define path. - _define = define_xml if define_xml is not None else ( - self.define if isinstance(self.define, (str, Path)) else None + _define = ( + define_xml + if define_xml is not None + else (self.define if isinstance(self.define, (str, Path)) else None) ) result = engine.run(self.datasets, define_xml=_define) return ConformanceReport(native_result=result, package=self, agency=agency) @@ -727,9 +732,7 @@ def _add_supp_checks( dimension="consistency", ) - def _add_relrec_checks( - self, validation: Validate, data: Any, cols: set, has_dm: bool - ) -> None: + def _add_relrec_checks(self, validation: Validate, data: Any, cols: set, has_dm: bool) -> None: """RELREC (Related Records) resolution checks (lightweight).""" present_domains = set(self.datasets.keys()) if "RDOMAIN" in cols: @@ -886,8 +889,7 @@ def check(data: Any) -> list[bool]: if column not in df.columns: return [True] return [ - (True if (v is None and na_pass) else (v in valid_values)) - for v in df[column].to_list() + (True if (v is None and na_pass) else (v in valid_values)) for v in df[column].to_list() ] return check @@ -1365,9 +1367,9 @@ def to_excel(self, path: str | Path) -> Path: if issues: pd.DataFrame(issues).to_excel(writer, sheet_name="Issues", index=False) s = self.summary() - pd.DataFrame( - [{"Key": k, "Value": str(v)} for k, v in s.items()] - ).to_excel(writer, sheet_name="Summary", index=False) + pd.DataFrame([{"Key": k, "Value": str(v)} for k, v in s.items()]).to_excel( + writer, sheet_name="Summary", index=False + ) else: issues = self.issues() if issues: @@ -1378,36 +1380,191 @@ def to_excel(self, path: str | Path) -> Path: return dest + def get_tabular_report(self) -> "GT": + """Build a Great Tables conformance report for the native rules engine.""" + from importlib.metadata import version as _pkg_version + + import polars as pl + from great_tables import GT, from_column, google_font, html, loc, style + + if not self.is_rules: + raise TypeError( + "get_tabular_report() is only available for native rules results. " + "Use validate_conformance(engine='native') to obtain one." + ) + + nr = self.native_result + + _STATUS_COLORS = { + "pass": "#4CA64C", + "fail": "#FF3300", + "error": "#EBBC14", + "not_applicable": "#AAAAAA", + "not_supported": "#AAAAAA", + } + _TYPE_LABELS = { + "RECORD_CHECK": "Record", + "VARIABLE_METADATA_CHECK": "Variable", + "DATASET_CONTENTS_CHECK": "Dataset", + "DOMAIN_PRESENCE_CHECK": "Domain", + "DATASET_METADATA_CHECK": "Metadata", + "DEFINE_ITEM_METADATA_CHECK": "Define", + "DEFINE_CODELIST_CHECK": "Codelist", + } + _STATUS_PRIORITY = { + "fail": 0, + "error": 1, + "pass": 2, + "not_applicable": 3, + "not_supported": 4, + } + + rows = sorted( + nr.rule_results, + key=lambda r: (_STATUS_PRIORITY.get(r.status, 5), -r.n_issues), + ) + + def _fmt_dataset(ds: str) -> str: + parts = [p.strip() for p in ds.split(",")] + supp = [p for p in parts if p.startswith("SUPP")] + other = [p for p in parts if not p.startswith("SUPP")] + if len(supp) > 1: + abbrev = "SUPP--: " + ", ".join(p[4:] for p in supp) + return ", ".join(other + [abbrev]) if other else abbrev + return ds + + desc_max = 90 + data = { + "status_color": [_STATUS_COLORS.get(r.status, "#AAAAAA") for r in rows], + "rule_id": [r.rule_id for r in rows], + "dataset": [_fmt_dataset(r.dataset) for r in rows], + "type": [_TYPE_LABELS.get(r.rule_type, r.rule_type) for r in rows], + "n_issues": [r.n_issues for r in rows], + "description": [ + r.description[:desc_max] + "…" if len(r.description) > desc_max else r.description + for r in rows + ], + } + df = pl.DataFrame(data) + + # indices of rows with at least one issue (for red-text styling) + issue_indices = [i for i, r in enumerate(rows) if r.n_issues > 0] + + counts = nr.status_counts() + counts_parts = [] + _LABEL = { + "pass": "passed", + "fail": "failed", + "error": "error", + "not_applicable": "n/a", + "not_supported": "unsupported", + } + for st in ("pass", "fail", "error", "not_applicable", "not_supported"): + n = counts.get(st, 0) + if n: + counts_parts.append(f"{n} {_LABEL[st]}") + counts_str = " · ".join(counts_parts) + + agency_part = f" · {self.agency}" if self.agency else "" + subtitle_text = ( + f"{nr.standard.upper()} {nr.version}{agency_part} · {counts_str}" + ) + + ct_note = "" + if nr.ct_packages: + ct_note = "CT: " + " | ".join(nr.ct_packages) + + overall_passed = nr.all_passed + status_label = "PASS" if overall_passed else "FAIL" + status_color = "#4CA64C" if overall_passed else "#FF3300" + status_html = ( + f'{status_label}' + ) + title_text = f"CDISC Conformance {status_html}" + + gt_tbl = ( + GT(df, id="pb_conformance_tbl") + .tab_header( + title=html(title_text), + subtitle=html(subtitle_text), + ) + .opt_table_font(font=google_font(name="IBM Plex Sans")) + .opt_align_table_header(align="left") + # ── status color bar ────────────────────────────────────────── + .tab_style( + style=style.fill(color=from_column(column="status_color")), + locations=loc.body(columns="status_color"), + ) + .tab_style( + style=style.text(color="transparent", size="0px"), + locations=loc.body(columns="status_color"), + ) + # ── monospace columns ───────────────────────────────────────── + .tab_style( + style=style.text(font=google_font(name="IBM Plex Mono"), size="11px"), + locations=loc.body( + columns=["rule_id", "dataset", "type", "n_issues", "description"] + ), + ) + # ── issues column: red when non-zero ────────────────────────── + .tab_style( + style=style.text(color="#c62828", weight="bold"), + locations=loc.body(columns="n_issues", rows=issue_indices), + ) + # ── row height ──────────────────────────────────────────────── + .tab_style( + style=style.css("padding-top: 2px; padding-bottom: 2px;"), + locations=loc.body(), + ) + # ── column labels ───────────────────────────────────────────── + .cols_label( + cases={ + "status_color": "", + "rule_id": "Rule", + "dataset": "Dataset", + "type": "Type", + "n_issues": "Issues", + "description": "Description", + } + ) + # ── column widths ───────────────────────────────────────────── + # Should be 904px wide, just like the validation report table. + .cols_width( + cases={ + "status_color": "4px", + "rule_id": "90px", + "dataset": "80px", + "type": "80px", + "n_issues": "50px", + "description": "600px", + } + ) + # ── alignment ───────────────────────────────────────────────── + .cols_align(align="center", columns=["n_issues"]) + .tab_options(table_font_size="90%") + ) + + if ct_note: + gt_tbl = gt_tbl.tab_source_note( + source_note=html(f'{ct_note}') + ) + + try: + if _pkg_version("great_tables") >= "0.17.0": + gt_tbl = gt_tbl.tab_options(quarto_disable_processing=True) + except Exception: + pass + + return gt_tbl + def _repr_html_(self) -> str: agency = f" — agency: {self.agency}" if self.agency else "" if self.is_rules: - nr = self.native_result - parts = [f"

CDISC Conformance Report (Native Rules){agency}

"] - status = "PASS" if nr.all_passed else "FAIL" - parts.append( - f"

{nr.standard} {nr.version} — " - f"{status}

" - ) - counts = nr.status_counts() - parts.append("
    ") - for st, n in sorted(counts.items()): - parts.append(f"
  • {st}: {n}
  • ") - parts.append(f"
  • Total issues: {nr.n_total_issues}
  • ") - parts.append("
") - failing = [r for r in nr.rule_results if r.n_issues > 0] - if failing: - parts.append( - "" - "" - ) - for r in failing: - parts.append( - f"" - f"" - ) - parts.append("
DatasetRuleIssuesMessage
{r.dataset}{r.rule_id}{r.n_issues}{r.message or r.description}
") - return "\n".join(parts) + return self.get_tabular_report()._repr_html_() if self.is_core: core = self.core @@ -1424,8 +1581,9 @@ def _repr_html_(self) -> str: parts.append(f"
  • Total issues: {core.n_total_issues}
  • ") parts.append("") if core.issue_summary: - parts.append("" - "") + parts.append( + "
    DatasetRuleIssuesMessage
    " + ) for item in core.issue_summary: parts.append( f"" From a4a48f924719586f029ae1ade170c7eaf74b52a9 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 14:05:34 -0400 Subject: [PATCH 65/93] Update SDTM CT with additional lab units --- .../conformance/ct/sdtm-ct-2024-09-27.json | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json b/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json index fe0fb2e36..7832d8963 100644 --- a/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json +++ b/pointblank/data/conformance/ct/sdtm-ct-2024-09-27.json @@ -283,17 +283,33 @@ "%", "10^3/uL", "10^6/uL", + "GI/L", "IU/L", + "mIU/mL", + "mU/L", "pg/mL", "ng/mL", "ug/mL", "mEq/L", "fL", "pg", + "pmol/L", + "fmol", + "fmol/L", + "fmol(Fe)", "seconds", "ratio", + "1", "nmol/L", - "umol/L" + "umol/L", + "10^9/L", + "10^12/L", + "TI/L", + "mg/g", + "ug/L", + "kU/L", + "mkat/L", + "ukat/L" ], "NRIND": [ "LOW", From c5cf43c3e70b0aa1c1ff087efb1c1bda4bf6f73d Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 14:05:54 -0400 Subject: [PATCH 66/93] Remove selected SDTM 3.4 conformance rules --- .../data/conformance/rules/sdtmig-3-4.json | 421 +----------------- 1 file changed, 2 insertions(+), 419 deletions(-) diff --git a/pointblank/data/conformance/rules/sdtmig-3-4.json b/pointblank/data/conformance/rules/sdtmig-3-4.json index 7517f0f16..a4d31323e 100644 --- a/pointblank/data/conformance/rules/sdtmig-3-4.json +++ b/pointblank/data/conformance/rules/sdtmig-3-4.json @@ -3,7 +3,7 @@ "version": "3.4", "generated": "2026-07-14T00:00:00Z", "source": "CDISC SDTM Implementation Guide 3.4, hand-curated from public specification", - "checksum": "d46f73e9d2244fdb", + "checksum": "5146d20551cceca6", "rules": [ { "core_id": "SDTM-001", @@ -11026,144 +11026,6 @@ } } }, - { - "core_id": "SDTM-252", - "rule_type": "RECORD_CHECK", - "executability": "Fully Executable", - "sensitivity": "Error", - "description": "AEENDTC in AE must conform to ISO 8601 format when present.", - "authority": "CDISC", - "standards": [ - "sdtmig" - ], - "classes": [ - "Events" - ], - "domains": [ - "AE" - ], - "datasets": [], - "operations": [ - { - "operator": "iso8601_check", - "params": { - "column": "AEENDTC" - } - } - ], - "conditions": { - "all": [ - { - "name": "AEENDTC", - "operator": "is_not_null", - "value": null - }, - { - "name": "_pb_AEENDTC_iso8601", - "operator": "equal_to", - "value": false - } - ] - }, - "actions": { - "id": "generate_record_error", - "params": { - "message": "AEENDTC must conform to ISO 8601 format in AE." - } - } - }, - { - "core_id": "SDTM-253", - "rule_type": "RECORD_CHECK", - "executability": "Fully Executable", - "sensitivity": "Error", - "description": "CMSTDTC in CM must conform to ISO 8601 format when present.", - "authority": "CDISC", - "standards": [ - "sdtmig" - ], - "classes": [ - "Interventions" - ], - "domains": [ - "CM" - ], - "datasets": [], - "operations": [ - { - "operator": "iso8601_check", - "params": { - "column": "CMSTDTC" - } - } - ], - "conditions": { - "all": [ - { - "name": "CMSTDTC", - "operator": "is_not_null", - "value": null - }, - { - "name": "_pb_CMSTDTC_iso8601", - "operator": "equal_to", - "value": false - } - ] - }, - "actions": { - "id": "generate_record_error", - "params": { - "message": "CMSTDTC must conform to ISO 8601 format in CM." - } - } - }, - { - "core_id": "SDTM-254", - "rule_type": "RECORD_CHECK", - "executability": "Fully Executable", - "sensitivity": "Error", - "description": "CMENDTC in CM must conform to ISO 8601 format when present.", - "authority": "CDISC", - "standards": [ - "sdtmig" - ], - "classes": [ - "Interventions" - ], - "domains": [ - "CM" - ], - "datasets": [], - "operations": [ - { - "operator": "iso8601_check", - "params": { - "column": "CMENDTC" - } - } - ], - "conditions": { - "all": [ - { - "name": "CMENDTC", - "operator": "is_not_null", - "value": null - }, - { - "name": "_pb_CMENDTC_iso8601", - "operator": "equal_to", - "value": false - } - ] - }, - "actions": { - "id": "generate_record_error", - "params": { - "message": "CMENDTC must conform to ISO 8601 format in CM." - } - } - }, { "core_id": "SDTM-255", "rule_type": "RECORD_CHECK", @@ -11210,98 +11072,6 @@ } } }, - { - "core_id": "SDTM-256", - "rule_type": "RECORD_CHECK", - "executability": "Fully Executable", - "sensitivity": "Error", - "description": "EXENDTC in EX must conform to ISO 8601 format when present.", - "authority": "CDISC", - "standards": [ - "sdtmig" - ], - "classes": [ - "Interventions" - ], - "domains": [ - "EX" - ], - "datasets": [], - "operations": [ - { - "operator": "iso8601_check", - "params": { - "column": "EXENDTC" - } - } - ], - "conditions": { - "all": [ - { - "name": "EXENDTC", - "operator": "is_not_null", - "value": null - }, - { - "name": "_pb_EXENDTC_iso8601", - "operator": "equal_to", - "value": false - } - ] - }, - "actions": { - "id": "generate_record_error", - "params": { - "message": "EXENDTC must conform to ISO 8601 format in EX." - } - } - }, - { - "core_id": "SDTM-257", - "rule_type": "RECORD_CHECK", - "executability": "Fully Executable", - "sensitivity": "Error", - "description": "MHSTDTC in MH must conform to ISO 8601 format when present.", - "authority": "CDISC", - "standards": [ - "sdtmig" - ], - "classes": [ - "Events" - ], - "domains": [ - "MH" - ], - "datasets": [], - "operations": [ - { - "operator": "iso8601_check", - "params": { - "column": "MHSTDTC" - } - } - ], - "conditions": { - "all": [ - { - "name": "MHSTDTC", - "operator": "is_not_null", - "value": null - }, - { - "name": "_pb_MHSTDTC_iso8601", - "operator": "equal_to", - "value": false - } - ] - }, - "actions": { - "id": "generate_record_error", - "params": { - "message": "MHSTDTC must conform to ISO 8601 format in MH." - } - } - }, { "core_id": "SDTM-258", "rule_type": "RECORD_CHECK", @@ -11394,52 +11164,6 @@ } } }, - { - "core_id": "SDTM-260", - "rule_type": "RECORD_CHECK", - "executability": "Fully Executable", - "sensitivity": "Error", - "description": "DTHDTC in DM must conform to ISO 8601 format when present.", - "authority": "CDISC", - "standards": [ - "sdtmig" - ], - "classes": [ - "Special-Purpose" - ], - "domains": [ - "DM" - ], - "datasets": [], - "operations": [ - { - "operator": "iso8601_check", - "params": { - "column": "DTHDTC" - } - } - ], - "conditions": { - "all": [ - { - "name": "DTHDTC", - "operator": "is_not_null", - "value": null - }, - { - "name": "_pb_DTHDTC_iso8601", - "operator": "equal_to", - "value": false - } - ] - }, - "actions": { - "id": "generate_record_error", - "params": { - "message": "DTHDTC must conform to ISO 8601 format in DM." - } - } - }, { "core_id": "SDTM-261", "rule_type": "RECORD_CHECK", @@ -12254,53 +11978,6 @@ } } }, - { - "core_id": "SDTM-284", - "rule_type": "RECORD_CHECK", - "executability": "Fully Executable", - "sensitivity": "Error", - "description": "LBNRIND (reference range indicator) must use values from the NRIND controlled terminology codelist when present in LB.", - "authority": "CDISC", - "standards": [ - "sdtmig" - ], - "classes": [ - "Findings" - ], - "domains": [ - "LB" - ], - "datasets": [], - "operations": [ - { - "operator": "codelist_check", - "params": { - "column": "LBNRIND", - "codelist": "NRIND" - } - } - ], - "conditions": { - "all": [ - { - "name": "LBNRIND", - "operator": "is_not_null", - "value": null - }, - { - "name": "_pb_LBNRIND_valid", - "operator": "equal_to", - "value": false - } - ] - }, - "actions": { - "id": "generate_record_error", - "params": { - "message": "LBNRIND must use values from the NRIND codelist in LB." - } - } - }, { "core_id": "SDTM-285", "rule_type": "RECORD_CHECK", @@ -15456,53 +15133,6 @@ } } }, - { - "core_id": "SDTM-367", - "rule_type": "RECORD_CHECK", - "executability": "Fully Executable", - "sensitivity": "Error", - "authority": "CDISC", - "standards": [ - "sdtmig" - ], - "description": "DTHFL in DM must use the NY (No Yes) controlled terminology codelist when present.", - "classes": [ - "Special-Purpose" - ], - "domains": [ - "DM" - ], - "datasets": [], - "operations": [ - { - "operator": "codelist_check", - "params": { - "column": "DTHFL", - "codelist": "NY" - } - } - ], - "conditions": { - "all": [ - { - "name": "DTHFL", - "operator": "is_not_null", - "value": null - }, - { - "name": "_pb_DTHFL_valid", - "operator": "equal_to", - "value": false - } - ] - }, - "actions": { - "id": "generate_record_error", - "params": { - "message": "DTHFL must be a valid NY codelist value (Y or N) in DM." - } - } - }, { "core_id": "SDTM-368", "rule_type": "VARIABLE_METADATA_CHECK", @@ -17849,53 +17479,6 @@ } } }, - { - "core_id": "SDTM-431", - "rule_type": "RECORD_CHECK", - "executability": "Fully Executable", - "sensitivity": "Error", - "authority": "CDISC", - "standards": [ - "sdtmig" - ], - "description": "CMROUTE in CM must be a valid ROUTE codelist term when present.", - "classes": [ - "Interventions" - ], - "domains": [ - "CM" - ], - "datasets": [], - "operations": [ - { - "operator": "codelist_check", - "params": { - "column": "CMROUTE", - "codelist": "ROUTE" - } - } - ], - "conditions": { - "all": [ - { - "name": "CMROUTE", - "operator": "is_not_null", - "value": null - }, - { - "name": "_pb_CMROUTE_valid", - "operator": "equal_to", - "value": false - } - ] - }, - "actions": { - "id": "generate_record_error", - "params": { - "message": "CMROUTE must be a valid CDISC ROUTE term when present." - } - } - }, { "core_id": "SDTM-432", "rule_type": "RECORD_CHECK", @@ -18085,4 +17668,4 @@ } } ] -} +} \ No newline at end of file From 3126317d59b7f1bf26529f222b684cadc34a90c9 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 14:06:21 -0400 Subject: [PATCH 67/93] Export the validate_sdtmig() function --- pointblank/metadata/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pointblank/metadata/__init__.py b/pointblank/metadata/__init__.py index 5719208eb..8829b3cb2 100644 --- a/pointblank/metadata/__init__.py +++ b/pointblank/metadata/__init__.py @@ -29,6 +29,7 @@ ConformanceReport, SubmissionPackage, validate_cdisc_submission, + validate_sdtmig, ) from pointblank.metadata._types import ( Codelist, @@ -66,6 +67,7 @@ "SubmissionPackage", "ConformanceReport", "validate_cdisc_submission", + "validate_sdtmig", "CoreFinding", "CoreRuleResult", "CoreIssueSummary", From 5b83a9e807f81f6b8db798554b14a78b4b1b74ae Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 14:06:24 -0400 Subject: [PATCH 68/93] Update __init__.py --- pointblank/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pointblank/__init__.py b/pointblank/__init__.py index a1ce64645..52f8fba31 100644 --- a/pointblank/__init__.py +++ b/pointblank/__init__.py @@ -86,6 +86,7 @@ validate_cdisc_submission, validate_sdtm, validate_sdtm_structure, + validate_sdtmig, ) from pointblank.pipeline import Pipeline, PipelineResult from pointblank.schema import Schema, generate_dataset, schema_from_tbl @@ -224,4 +225,5 @@ "SubmissionPackage", "ConformanceReport", "validate_cdisc_submission", + "validate_sdtmig", ] From 2af8edb8a03300d2c4c8fb9bca3d2d42392701ff Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 14:06:39 -0400 Subject: [PATCH 69/93] Skip structural datasets in catch-all checks --- pointblank/metadata/_conformance/engine.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/pointblank/metadata/_conformance/engine.py b/pointblank/metadata/_conformance/engine.py index 8e517f524..8f81c1995 100644 --- a/pointblank/metadata/_conformance/engine.py +++ b/pointblank/metadata/_conformance/engine.py @@ -39,6 +39,10 @@ "DEFINE_CODELIST_CHECK", } +# SUPP-- and RELREC use RDOMAIN instead of DOMAIN and have a fixed non-standard structure. +# Catch-all rules (domains: []) must not automatically apply to them. +_STRUCTURAL_DATASETS = frozenset({"RELREC"}) + # Maximum row-level findings to collect per rule (avoids blowing up memory on large datasets) _MAX_FINDINGS = 100 @@ -216,7 +220,14 @@ def _record_check( self, rule: NativeRule, datasets: dict[str, nw.DataFrame] ) -> NativeRuleResult: """Per-row check: find rows where the condition tree evaluates to True (= violation).""" - target_domains = rule.domains or list(datasets.keys()) + if rule.domains: + target_domains = rule.domains + else: + # Exclude SUPP-- and RELREC from catch-all iteration; they have non-standard structure. + target_domains = [ + k for k in datasets + if not k.startswith("SUPP") and k not in _STRUCTURAL_DATASETS + ] all_findings: list[NativeRowFinding] = [] n_issues = 0 @@ -275,7 +286,14 @@ def _dataset_metadata_check( Conditions reference computed columns added by operations (e.g. `$USUBJID_present`). """ - target_domains = rule.domains or list(datasets.keys()) + if rule.domains: + target_domains = rule.domains + else: + # Exclude SUPP-- and RELREC from catch-all iteration; they have non-standard structure. + target_domains = [ + k for k in datasets + if not k.startswith("SUPP") and k not in _STRUCTURAL_DATASETS + ] n_issues = 0 first_failing_domain = "" From aa172f296f1d3959f10098a3986abad463ac2979 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 14:06:54 -0400 Subject: [PATCH 70/93] Handle SAS/XPT empty strings in conformance checks --- pointblank/metadata/_conformance/operations.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/pointblank/metadata/_conformance/operations.py b/pointblank/metadata/_conformance/operations.py index 4107e2ce5..f4c721ba8 100644 --- a/pointblank/metadata/_conformance/operations.py +++ b/pointblank/metadata/_conformance/operations.py @@ -80,7 +80,7 @@ def _op_codelist_check( datasets: dict[str, nw.DataFrame], define_meta: Any = None, ) -> nw.DataFrame: - """Add `_pb__valid` (`True` = value in codelist or null).""" + """Add `_pb__valid` (`True` = value in codelist, null, or empty string).""" col: str = params["column"] codelist: str = params["codelist"] result_col = f"_pb_{col}_valid" @@ -89,8 +89,10 @@ def _op_codelist_check( terms = ct.get_codelist(codelist) if terms is None: return df.with_columns(nw.lit(True).alias(result_col)) + # Build case-insensitive lookup; SAS/XPT missing values arrive as "" — treat as null. + upper_terms = {t.upper() for t in terms} values = df[col].to_list() - mask = [True if v is None else (str(v) in terms) for v in values] + mask = [True if (v is None or str(v) == "") else (str(v).upper() in upper_terms) for v in values] return df.with_columns(_new_bool_series(result_col, mask, df)) @@ -106,12 +108,13 @@ def _op_consistency_check( result_col = f"_pb_{col}_consistent" if col not in df.columns: return df.with_columns(nw.lit(True).alias(result_col)) - values = [v for v in df[col].to_list() if v is not None] + # Exclude null and SAS/XPT empty strings from mode calculation. + values = [v for v in df[col].to_list() if v is not None and str(v) != ""] if not values: return df.with_columns(nw.lit(True).alias(result_col)) expected = Counter(values).most_common(1)[0][0] rows = df[col].to_list() - mask = [True if v is None else (v == expected) for v in rows] + mask = [True if (v is None or str(v) == "") else (v == expected) for v in rows] return df.with_columns(_new_bool_series(result_col, mask, df)) @@ -128,7 +131,8 @@ def _op_iso8601_check( if col not in df.columns: return df.with_columns(nw.lit(True).alias(result_col)) values = df[col].to_list() - mask = [True if v is None else is_iso8601(str(v)) for v in values] + # SAS/XPT missing character values arrive as ""; treat as null (no violation). + mask = [True if (v is None or str(v) == "") else is_iso8601(str(v)) for v in values] return df.with_columns(_new_bool_series(result_col, mask, df)) From ec0f7369350611a4effa52886d79f3b9110e5ff8 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 14:07:19 -0400 Subject: [PATCH 71/93] Add the validate_sdtmig() conformance helper --- pointblank/metadata/_submission.py | 66 ++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/pointblank/metadata/_submission.py b/pointblank/metadata/_submission.py index 7fc709ce6..c783fb02c 100644 --- a/pointblank/metadata/_submission.py +++ b/pointblank/metadata/_submission.py @@ -1760,3 +1760,69 @@ def validate_cdisc_submission( cache=cache, workdir=workdir, ) + + +def validate_sdtmig( + datasets: dict, + version: str = "3-4", + ct_packages: list[str] | None = None, + define_xml: Any = None, + study_id: str | None = None, +) -> ConformanceReport: + """Check SDTMIG conformance and return a renderable report. + + Runs the bundled SDTMIG rule catalog against the provided datasets. No external tools, + subprocesses, or network calls are required. The returned [`ConformanceReport`] renders as a + color-coded Great Tables summary. + + Parameters + ---------- + datasets + Mapping of domain name to DataFrame. Keys are case-insensitive (e.g., `"DM"` or `"dm"`). + Accepts Polars, pandas, or any narwhals-supported DataFrame. + version + SDTMIG version. Currently `"3-4"` (default) is the only bundled catalog. + ct_packages + Controlled Terminology package name(s) to load (e.g., `["sdtm-ct-2024-09-27"]`). Defaults + to the latest bundled CT package. + define_xml + Optional path to a `define.xml` file or a pre-parsed `MetadataPackage`. When provided, + Define-XML-aware rules (codelist declarations, mandatory variables) are activated. + study_id + Optional study identifier shown in the report header. + + Returns + ------- + ConformanceReport + A native-rules report (`is_rules` is `True`). Displays as a Great Tables table in + notebooks; call `.get_tabular_report()` to get the `GT` object directly. + + Examples + -------- + ```python + import polars as pl + from pointblank.metadata import validate_sdtmig + + dm = pl.read_parquet("sdtm/dm.parquet") + ae = pl.read_parquet("sdtm/ae.parquet") + + report = validate_sdtmig({"DM": dm, "AE": ae}) + report + ``` + """ + + # Normalise version separator (accept "3.4" or "3-4") + _ver = version.replace(".", "-") + pkg = SubmissionPackage( + datasets=datasets, + standard="sdtmig", + standard_version=_ver, + study_id=study_id, + ) + return pkg._run_rules_conformance( + agency=None, + standard="sdtmig", + version=_ver, + ct_packages=ct_packages, + define_xml=define_xml, + ) From 816424fdf2f17e7e04e1fe0e5c3a57192b82e80a Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 14:08:35 -0400 Subject: [PATCH 72/93] Add CDISCPILOT01 SDTM smoke test suite --- tests/test_cdiscpilot01_smoke.py | 341 +++++++++++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 tests/test_cdiscpilot01_smoke.py diff --git a/tests/test_cdiscpilot01_smoke.py b/tests/test_cdiscpilot01_smoke.py new file mode 100644 index 000000000..7e94c7236 --- /dev/null +++ b/tests/test_cdiscpilot01_smoke.py @@ -0,0 +1,341 @@ +"""Smoke tests against the CDISC CDISCPILOT01 public reference dataset. + +CDISCPILOT01 is the canonical public SDTM reference submission maintained by CDISC at: + + https://github.com/cdisc-org/sdtm-adam-pilot-project + +Running these tests provides validation evidence that the Pointblank native conformance engine +produces sensible, correctly-scoped results on real SDTM data. This is an important bit of assurance +for pharmaceutical users. + +How to obtain the data +---------------------- +Download or clone the CDISC pilot repository and point CDISCPILOT01_PATH at the XPT folder: + + git clone https://github.com/cdisc-org/sdtm-adam-pilot-project + export CDISCPILOT01_PATH=sdtm-adam-pilot-project/updated-pilot-submission-package/900172/m5/datasets/cdiscpilot01/tabulations/sdtm + +Or set the variable directly when running pytest: + + CDISCPILOT01_PATH=/path/to/sdtm pytest tests/test_cdiscpilot01_smoke.py -v + +These tests are skipped automatically when CDISCPILOT01_PATH is not set. + +Validated against: SDTMIG 3.4 conformance catalog, 426 rules, CT 2024-09-27. +Study: CDISCPILOT01, Alzheimer's disease study, 306 subjects, 18 SDTM + 4 SUPP-- domains. +""" + +from __future__ import annotations + +import os +import pathlib +from collections import Counter + +import pytest + +# ── Path resolution ──────────────────────────────────────────────────────────── + +_PILOT_PATH_ENV = "CDISCPILOT01_PATH" +_PILOT_FALLBACK = pathlib.Path("/tmp/cdiscpilot01") + +_PILOT_DOMAIN_FILES = ["dm", "ae", "cm", "ex", "lb", "vs"] # minimum for a meaningful run + +_pilot_path: pathlib.Path | None = None +if env := os.getenv(_PILOT_PATH_ENV): + _pilot_path = pathlib.Path(env) +elif _PILOT_FALLBACK.exists() and any(_PILOT_FALLBACK.glob("dm.xpt")): + _pilot_path = _PILOT_FALLBACK + +_DATA_AVAILABLE = _pilot_path is not None and all( + (_pilot_path / f"{d}.xpt").exists() for d in _PILOT_DOMAIN_FILES +) + +pytestmark = pytest.mark.skipif( + not _DATA_AVAILABLE, + reason=( + "CDISCPILOT01 XPT files not found. " + f"Set {_PILOT_PATH_ENV}=/path/to/sdtm to enable these tests." + ), +) + + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def pilot_datasets(): + """Load all available CDISCPILOT01 XPT datasets into a {NAME: DataFrame} dict.""" + try: + import pyreadstat + import polars as pl + except ImportError: + pytest.skip("pyreadstat and polars are required for CDISCPILOT01 tests.") + + assert _pilot_path is not None + datasets: dict = {} + + # Standard SDTM domains present in the pilot + sdtm_domains = [ + "dm", + "ae", + "cm", + "ex", + "lb", + "vs", + "mh", + "ds", + "sv", + "sc", + "qs", + "ta", + "ti", + "te", + "tv", + "eg", + "fa", + ] + for name in sdtm_domains: + f = _pilot_path / f"{name}.xpt" + if f.exists(): + kw = {"encoding": "cp1252"} if name == "ts" else {} + df_pd, _ = pyreadstat.read_xport(str(f), **kw) + datasets[name.upper()] = pl.from_pandas(df_pd) + + # Trial summary (TS) — needs cp1252 encoding + ts_f = _pilot_path / "ts.xpt" + if ts_f.exists(): + df_pd, _ = pyreadstat.read_xport(str(ts_f), encoding="cp1252") + datasets["TS"] = pl.from_pandas(df_pd) + + # SUPP-- datasets + supp_names = ["suppdm", "suppae", "supplb", "suppvs", "suppeg", "suppcm", "suppds", "suppmh"] + for name in supp_names: + f = _pilot_path / f"{name}.xpt" + if f.exists(): + df_pd, _ = pyreadstat.read_xport(str(f)) + datasets[name.upper()] = pl.from_pandas(df_pd) + + return datasets + + +@pytest.fixture(scope="module") +def pilot_report(pilot_datasets): + """Run the native SDTMIG 3.4 engine against the full pilot and return the ConformanceReport.""" + import pointblank as pb + + return pb.validate_sdtmig(pilot_datasets) + + +# ── Basic result structure ───────────────────────────────────────────────────── + + +def test_report_is_rules_type(pilot_report): + assert pilot_report.is_rules + assert not pilot_report.is_core + + +def test_report_has_expected_rule_count(pilot_report): + """426 rules in the SDTMIG 3.4 catalog after deduplication (as of 2025-07-14).""" + assert len(pilot_report.native_result.rule_results) == 426 + + +def test_report_overall_pass_rate(pilot_report): + """Expect >= 93% pass rate on the reference pilot dataset.""" + results = pilot_report.native_result.rule_results + passed = sum(1 for r in results if r.status == "pass") + total_executed = sum(1 for r in results if r.status in ("pass", "fail")) + pass_rate = passed / total_executed if total_executed else 0 + assert pass_rate >= 0.93, ( + f"Pass rate {pass_rate:.1%} is below 93% — check for regressions or CT expansion gaps." + ) + + +def test_report_total_issues_is_bounded(pilot_report): + """Total issue count must stay well under 5,000 (pre-fix it was 127,357).""" + total = sum(r.n_issues for r in pilot_report.native_result.rule_results) + assert total < 5_000, ( + f"Total issues = {total:,}. " + "This may indicate a regression in empty-string handling or duplicate rules." + ) + + +def test_no_duplicate_rule_ids(pilot_report): + ids = [r.rule_id for r in pilot_report.native_result.rule_results] + counts = Counter(ids) + duplicates = {k: v for k, v in counts.items() if v > 1} + assert not duplicates, f"Duplicate rule IDs in results: {duplicates}" + + +# ── Domain coverage ──────────────────────────────────────────────────────────── + + +def test_dm_domain_loaded(pilot_datasets): + assert "DM" in pilot_datasets + assert len(pilot_datasets["DM"]) == 306 # 306 subjects in the pilot + + +def test_core_domains_present(pilot_datasets): + for domain in ["DM", "AE", "CM", "EX", "LB", "VS", "TA", "TS"]: + assert domain in pilot_datasets, f"{domain} not loaded" + + +# ── Known legitimate findings in CDISCPILOT01 ───────────────────────────────── +# +# CDISCPILOT01 was created against SDTMIG 3.1.2 with CT from circa 2012. The +# findings below reflect genuine differences between the pilot and current +# SDTMIG 3.4 / CT-2024-09-27, NOT bugs in the conformance engine. + + +def test_aerel_finding_is_present(pilot_report): + """AEREL uses old CT terms (PROBABLE/POSSIBLE/REMOTE/NONE); current CT expects longer phrases. + + This is an expected, legitimate finding — the pilot predates the current AEREL codelist. + If this test starts failing (0 issues) the AEREL rule may have been accidentally relaxed. + """ + aerel = next( + (r for r in pilot_report.native_result.rule_results if r.rule_id == "SDTM-135"), + None, + ) + assert aerel is not None, "SDTM-135 (AEREL CT check) not found in results" + assert aerel.n_issues > 0, ( + "Expected AEREL findings against CDISCPILOT01 — " + "old CT terms should not be silently accepted." + ) + # Upper bound: all 1,191 AE records + assert aerel.n_issues <= 1_200 + + +def test_visitnum_in_ae_finding(pilot_report): + """VISITNUM is absent from the pilot AE domain (it's optional in SDTM AE events).""" + rule = next( + (r for r in pilot_report.native_result.rule_results if r.rule_id == "SDTM-162"), + None, + ) + assert rule is not None + assert rule.n_issues == 1 # one dataset (AE) fails the column-presence check + + +def test_lbornrlo_type_finding(pilot_report): + """LBORNRLO is character in the pilot (stores 'SEE TEXT'); SDTMIG requires numeric type.""" + rule = next( + (r for r in pilot_report.native_result.rule_results if r.rule_id == "SDTM-170"), + None, + ) + assert rule is not None + assert rule.n_issues >= 1 + + +# ── Engine correctness: empty-string handling ────────────────────────────────── + + +def test_lbblfl_false_positives_eliminated(pilot_report): + """After SAS empty-string fix, LBBLFL (baseline flag) must not produce issues. + + Pre-fix: 50,347 issues (SAS missing values read as '' not null). + Post-fix: 0 issues (empty strings treated as missing → skip codelist check). + """ + rule = next( + (r for r in pilot_report.native_result.rule_results if r.rule_id == "SDTM-095"), + None, + ) + assert rule is not None, "SDTM-095 (LBBLFL NY check) not found" + assert rule.n_issues == 0, ( + f"SDTM-095 has {rule.n_issues} issues — SAS empty-string handling may be broken." + ) + + +def test_vsblfl_false_positives_eliminated(pilot_report): + """VSBLFL baseline flag produces 0 issues after SAS empty-string fix (pre-fix: 26,860).""" + rule = next( + (r for r in pilot_report.native_result.rule_results if r.rule_id == "SDTM-093"), + None, + ) + assert rule is not None + assert rule.n_issues == 0, ( + f"SDTM-093 has {rule.n_issues} issues — SAS empty-string handling may be broken." + ) + + +def test_rficdtc_false_positives_eliminated(pilot_report): + """RFICDTC ISO 8601 check produces 0 issues (pre-fix: 306 issues from '' empty strings).""" + rule = next( + (r for r in pilot_report.native_result.rule_results if r.rule_id == "SDTM-193"), + None, + ) + # Rule may be not_applicable if RFICDTC column is absent; either way it must not produce + # false positives from empty strings. + if rule is not None and rule.status != "not_applicable": + assert rule.n_issues == 0, ( + f"SDTM-193 has {rule.n_issues} issues — check SAS empty-string handling." + ) + + +# ── Engine correctness: case-insensitive CT comparison ──────────────────────── + + +def test_epoch_ct_case_insensitive(pilot_report): + """TA EPOCH values ('Screening', 'Treatment') must match the uppercase EPOCH codelist. + + Pre-fix: 8 issues (case-sensitive comparison failed 'Screening' vs 'SCREENING'). + Post-fix: 0 issues. + """ + rule = next( + (r for r in pilot_report.native_result.rule_results if r.rule_id == "SDTM-434"), + None, + ) + if rule is not None and rule.status != "not_applicable": + assert rule.n_issues == 0, ( + f"SDTM-434 has {rule.n_issues} EPOCH CT issues — " + "case-insensitive CT comparison may be broken." + ) + + +def test_vsstresu_case_insensitive(pilot_report): + """VSSTRESU 'BEATS/MIN' must match codelist 'beats/min' via case-insensitive comparison.""" + rule = next( + (r for r in pilot_report.native_result.rule_results if r.rule_id == "SDTM-092"), + None, + ) + if rule is not None and rule.status != "not_applicable": + assert rule.n_issues == 0, ( + f"SDTM-092 has {rule.n_issues} VSSTRESU issues — " + "case-insensitive CT comparison may be broken." + ) + + +# ── Engine correctness: SUPP-- excluded from catch-all rules ────────────────── + + +def test_domain_column_presence_excludes_supp(pilot_report): + """SDTM-029 (DOMAIN column required) must not fire on SUPP-- datasets. + + SUPP-- datasets use RDOMAIN, not DOMAIN. Pre-fix: 4 issues (one per SUPP-- dataset). + Post-fix: 0 issues. + """ + rule = next( + (r for r in pilot_report.native_result.rule_results if r.rule_id == "SDTM-029"), + None, + ) + assert rule is not None + assert rule.n_issues == 0, ( + f"SDTM-029 has {rule.n_issues} issues — SUPP-- datasets are being incorrectly " + "included in catch-all domain iteration." + ) + + +# ── get_tabular_report rendering ────────────────────────────────────────────── + + +def test_get_tabular_report_returns_gt(pilot_report): + """get_tabular_report() must return a Great Tables GT object.""" + try: + from great_tables import GT + except ImportError: + pytest.skip("great_tables not installed") + gt = pilot_report.get_tabular_report() + assert isinstance(gt, GT) + + +def test_repr_html_non_empty(pilot_report): + html = pilot_report._repr_html_() + assert html and len(html) > 500 From 3c9bf083b74733f9d0e83b97af8f4069a1b78948 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 14:08:40 -0400 Subject: [PATCH 73/93] Update test_native_conformance.py --- tests/test_native_conformance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_native_conformance.py b/tests/test_native_conformance.py index 730a0e819..e4fa0596d 100644 --- a/tests/test_native_conformance.py +++ b/tests/test_native_conformance.py @@ -388,7 +388,7 @@ def test_engine_clean_dm_zero_issues(clean_result): def test_engine_rule_count(clean_result): - assert len(clean_result.rule_results) == 435 + assert len(clean_result.rule_results) == 426 def test_engine_result_types(clean_result): @@ -1132,4 +1132,4 @@ def test_engine_accepts_metadata_import_directly(): def test_engine_rule_count_phase3(): engine = NativeConformanceEngine("sdtmig", "3.4") result = engine.run({"DM": _clean_dm()}) - assert len(result.rule_results) == 435 + assert len(result.rule_results) == 426 From 5d9f5d924420765aefc7beed41902df7e673a4f7 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 17:35:10 -0400 Subject: [PATCH 74/93] Improve row finding context in conformance engine --- pointblank/metadata/_conformance/engine.py | 131 ++++++++++++++++++--- 1 file changed, 117 insertions(+), 14 deletions(-) diff --git a/pointblank/metadata/_conformance/engine.py b/pointblank/metadata/_conformance/engine.py index 8f81c1995..0432de907 100644 --- a/pointblank/metadata/_conformance/engine.py +++ b/pointblank/metadata/_conformance/engine.py @@ -1,4 +1,4 @@ -"""Native CDISC conformance engine. +"""Built-in CDISC conformance engine. Runs bundled JSON rule catalogs against a collection of DataFrames using narwhals expressions. No subprocesses, no external installs, no API calls at runtime. @@ -46,21 +46,125 @@ # Maximum row-level findings to collect per rule (avoids blowing up memory on large datasets) _MAX_FINDINGS = 100 +# Columns checked as candidate identifiers when building a row finding (in priority order). +# The first one that's present in the dataset is included in `context`. +_CONTEXT_CANDIDATES = [ + "STUDYID", "DOMAIN", "SUBJID", + "VISITNUM", "VISIT", "EPOCH", + "AESEQ", "CMSEQ", "LBSEQ", "VSSEQ", "EXSEQ", "MHSEQ", "DSSEQ", "EGSEQ", + "AETERM", "CMTRT", "LBTESTCD", "VSTESTCD", "EGTESTCD", +] + + +def _condition_columns(conditions: dict) -> list[str]: + """Return all column names referenced in a conditions tree, depth-first.""" + cols: list[str] = [] + for key in ("all", "any"): + for sub in conditions.get(key, []): + cols.extend(_condition_columns(sub)) + name = conditions.get("name") + if name: + cols.append(name) + return cols + + +def _build_row_finding( + df: nw.DataFrame, + row_idx: int, + domain: str, + operations: list[dict], + conditions: dict, + rule_id: str, + message: str | None, +) -> "NativeRowFinding": + """Build a NativeRowFinding with smart column selection. + + Captures USUBJID, the primary rule-checked column and its value, and a small set of + identifying context columns (STUDYID, VISITNUM, test-code, etc.). + """ + cols = set(df.columns) + + # USUBJID — the most important identifier for QC workflows + raw_id = df["USUBJID"][row_idx] if "USUBJID" in cols else None + usubjid = str(raw_id) if raw_id is not None else None + + # Primary checked column: first operation that names a column present in the dataset; + # fall back to columns referenced directly in the conditions tree (e.g. rules with no ops). + checked_col: str | None = None + for op in operations: + col = op.get("params", {}).get("column") + if col and col in cols: + checked_col = col + break + if checked_col is None: + for col in _condition_columns(conditions): + if col in cols: + checked_col = col + break + + checked_val: str | None = None + if checked_col: + raw = df[checked_col][row_idx] + checked_val = str(raw) if raw is not None else "" + + # Context: a small set of identifying columns (excluding USUBJID and checked_col) + context: dict[str, str] = {} + for c in _CONTEXT_CANDIDATES: + if c in cols and c != "USUBJID" and c != checked_col: + raw = df[c][row_idx] + if raw is not None: + s = str(raw) + if s and s != "None": + context[c] = s + + return NativeRowFinding( + rule_id=rule_id, + dataset=domain, + row=row_idx, + usubjid=usubjid, + checked_column=checked_col, + checked_value=checked_val, + context=context, + message=message, + ) + class NativeConformanceEngine: - """Run the bundled CDISC rule catalog against a collection of DataFrames. + """Evaluate a bundled CDISC rule catalog against a collection of DataFrames. + + This is the low-level engine that powers [`validate_sdtmig()`](`pointblank.validate_sdtmig`). + Most users should call that convenience function rather than instantiating this class + directly. Use `NativeConformanceEngine` when you need fine-grained control over which rule + types to run, or when integrating Pointblank's conformance engine into a larger pipeline. + + The engine loads the rule catalog for the requested standard and version from the bundled + JSON files shipped with Pointblank, then evaluates each rule against the supplied datasets + using narwhals expressions. No subprocesses, network calls, or external CDISC tools are + involved. + + Supported rule types + -------------------- + - ``RECORD_CHECK`` — per-row value checks; failing rows are collected as `NativeRowFinding` + objects (up to 100 per rule). + - ``VARIABLE_METADATA_CHECK`` — variable presence and column ordering. + - ``DATASET_METADATA_CHECK`` — dataset-level attributes (sort keys, required sort order). + - ``DATASET_CONTENTS_CHECK`` — dataset-level value constraints evaluated row-by-row. + - ``DOMAIN_PRESENCE_CHECK`` — required or prohibited domain presence. + - ``DEFINE_ITEM_METADATA_CHECK`` — variable declarations against Define-XML metadata. + - ``DEFINE_CODELIST_CHECK`` — codelist values against Define-XML declarations. Parameters ---------- standard - The CDISC standard slug (e.g. `"sdtmig"`). + CDISC standard slug (e.g., ``"sdtmig"``). version - The standard version (e.g. `"3.4"`). + Standard version string (e.g., ``"3-4"``). ct_packages - CT package slugs to load (e.g. `["sdtm-ct-2024-09-27"]`). If `None`, the most - recent bundled CT package is used automatically. + CT package slug(s) to load (e.g., ``["sdtm-ct-2024-09-27"]``). When ``None`` the + most recent bundled CT package is loaded automatically. rule_types - Optional list of rule types to evaluate. Defaults to all supported types. + Optional allowlist of rule types to evaluate. When ``None`` all supported types are + run. Pass a subset (e.g., ``["RECORD_CHECK"]``) to restrict the run. """ def __init__( @@ -244,15 +348,14 @@ def _record_check( failing_rows = [i for i, v in enumerate(mask.to_list()) if v] n_issues += len(failing_rows) for row_idx in failing_rows[:_MAX_FINDINGS]: - variables = df.columns[:5] - values = [str(df[c][row_idx]) for c in variables] all_findings.append( - NativeRowFinding( + _build_row_finding( + df=df, + row_idx=row_idx, + domain=domain, + operations=rule.operations, + conditions=rule.conditions, rule_id=rule.core_id, - dataset=domain, - row=row_idx, - variables=variables, - values=values, message=rule.message, ) ) From 8da4e932fc8eed63ef7bee832b431d96b0f2eeeb Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 17:36:18 -0400 Subject: [PATCH 75/93] Refine NativeRowFinding fields and docs --- pointblank/metadata/_conformance/result.py | 32 ++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/pointblank/metadata/_conformance/result.py b/pointblank/metadata/_conformance/result.py index 6aa3419ca..16ba2e7ea 100644 --- a/pointblank/metadata/_conformance/result.py +++ b/pointblank/metadata/_conformance/result.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any # Rule execution statuses STATUS_PASS = "pass" @@ -15,14 +14,37 @@ @dataclass class NativeRowFinding: - """A single row-level finding produced by a rule.""" + """A single row-level finding produced by a record check rule. + + Attributes + ---------- + rule_id + The rule that fired (e.g. `"SDTM-135"`). + dataset + The domain in which the violation was found (e.g. `"AE"`). + row + 0-based row index in the domain DataFrame. + usubjid + The `USUBJID` value at the failing row, or `None` if the column is absent. + checked_column + The primary variable the rule checks (e.g. `"AEREL"`). + checked_value + The actual value at `checked_column` for this row. + context + Additional identifying columns captured alongside the finding (e.g., + `{"AESEQ": "3", "VISITNUM": "4.0"}`). + message + Short message from the rule definition, or `None`. + """ rule_id: str dataset: str row: int | None - variables: list[str] - values: list[Any] - message: str + usubjid: str | None + checked_column: str | None + checked_value: str | None + context: dict[str, str] + message: str | None = None @dataclass From ec99388af3366b8a5299e9719c3c10c6ef3015f2 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 17:36:44 -0400 Subject: [PATCH 76/93] Add built-in conformance findings outputs --- pointblank/metadata/_submission.py | 481 ++++++++++++++++++++++++----- 1 file changed, 408 insertions(+), 73 deletions(-) diff --git a/pointblank/metadata/_submission.py b/pointblank/metadata/_submission.py index c783fb02c..637905cda 100644 --- a/pointblank/metadata/_submission.py +++ b/pointblank/metadata/_submission.py @@ -8,7 +8,7 @@ Where [`validate_sdtm()`](`pointblank.validate_sdtm`) and [`validate_adam()`](`pointblank.validate_adam`) validate a *single* dataset structurally, the [`SubmissionPackage`](`pointblank.SubmissionPackage`) validates a study as a graph of related -datasets, adding the cross-dataset checks that today send sponsors to Pinnacle 21 / CDISC CORE. +datasets. """ from __future__ import annotations @@ -442,18 +442,16 @@ def validate_conformance( Parameters ---------- agency - Optional agency rule-set selector (`"FDA"`, `"PMDA"`, or `None` for CDISC base - rules). Recorded on the report; agency-specific business rule sets are a later - phase, so this currently affects labeling only. + Optional agency rule-set selector (`"FDA"`, `"PMDA"`, or `None` for CDISC base rules). engine - `"native"` (default) or `"core"`. + `"native"` (the default) or `"core"`. cross_dataset - (Native only.) Whether to add cross-dataset conformance checks. Defaults to `True`. + (Validate-based engine only.) Whether to add cross-dataset conformance checks. Defaults to `True`. thresholds - (Native only.) Optional thresholds passed to each dataset's `Validate` (maps failing + (Validate-based engine only.) Optional thresholds passed to each dataset's `Validate` (maps failing test units onto Pointblank's warning/error/critical severity model). interrogate - (Native only.) Whether to interrogate (run) the validations before returning. + (Validate-based engine only.) Whether to interrogate (run) the validations before returning. standard (CORE only.) Override the CDISC standard sent to CORE. Defaults to the package's `standard` (e.g., `"sdtmig"`). @@ -478,7 +476,7 @@ def validate_conformance( Returns ------- ConformanceReport - A native-form report (per-dataset validations) or a CORE-form report, depending on + A built-in engine report (per-dataset validations) or a CORE-form report, depending on `engine`. """ if engine not in ("native", "core", "validate"): @@ -534,7 +532,7 @@ def _run_rules_conformance( ct_packages: list[str] | None, define_xml: Any = None, ) -> ConformanceReport | None: - """Run the native rule-based engine; returns None if no catalog is bundled.""" + """Run the built-in rule-based engine; returns None if no catalog is bundled.""" from pointblank.metadata._conformance.engine import NativeConformanceEngine from pointblank.metadata._conformance.rule_loader import RuleLoader @@ -919,31 +917,36 @@ def check(data: Any) -> list[bool]: @dataclass class ConformanceReport: - """The result of [`SubmissionPackage.validate_conformance()`](`pointblank.SubmissionPackage`). + """The result of a CDISC conformance validation run. - A `ConformanceReport` comes in one of two forms depending on the validation engine used: + A `ConformanceReport` is returned by [`validate_sdtmig()`](`pointblank.validate_sdtmig`) and + [`SubmissionPackage.validate_conformance()`](`pointblank.SubmissionPackage.validate_conformance`). + It exists in one of two forms depending on the engine used: - - **Native** (`engine="native"`, the default) — aggregates the per-dataset - [`Validate`](`pointblank.Validate`) objects produced for the submission package. Each - dataset's validation carries both its single-dataset structural checks and the cross-dataset - conformance checks that reference it. - - **CORE** (`engine="core"`) — wraps the results of the external CDISC CORE engine, holding its - rule-ID-keyed findings, per-rule run statuses, and run provenance. + - **Built-in rules engine** (`is_rules` is `True`) — produced by Pointblank's SDTMIG rule + catalog. Each rule is evaluated against the supplied datasets and receives one of five + statuses: `"pass"`, `"fail"`, `"error"`, `"not_applicable"`, or `"not_supported"`. Row-level + findings (the individual failing records) are collected for RECORD_CHECK rules and accessible + via [`findings_df()`](`pointblank.ConformanceReport.findings_df`) and + [`get_findings_table()`](`pointblank.ConformanceReport.get_findings_table`). + - **CDISC CORE** (`is_core` is `True`) — produced by the external CDISC CORE command-line + engine. Rule-keyed findings and run provenance are exposed via `findings()` and `rules()`. - The `all_passed()`, `summary()`, `issues()`, and rendering methods work for both forms; use the - `is_core` property to tell them apart. CORE-backed reports additionally expose `findings()` and - `rules()`. + In a Jupyter or Quarto notebook the report renders automatically as a color-coded rule summary + table (calling `_repr_html_()` is equivalent to `get_tabular_report()._repr_html_()`). Parameters ---------- validations - A mapping of dataset name to its interrogated `Validate` object (native form). + Reserved for legacy use; not populated by the built-in engine. package - The `SubmissionPackage` the report was produced from. + The `SubmissionPackage` the report was produced from, if any. agency - The agency rule-set selector used for the run (or `None` for CDISC base rules). + The agency rule-set selector used for the run (`None` for CDISC base rules). core - The parsed CDISC CORE report (CORE form). `None` for native reports. + The parsed CDISC CORE report (CORE form only). `None` for built-in engine reports. + native_result + The `NativeConformanceResult` produced by the rules engine (native form only). """ validations: dict[str, Validate] = dataclass_field(default_factory=dict) @@ -985,18 +988,18 @@ def from_core_report( @property def is_core(self) -> bool: - """Whether this report wraps CDISC CORE engine results (vs. native validations).""" + """Whether this report wraps CDISC CORE engine results (vs. built-in engine results).""" return self.core is not None @property def is_rules(self) -> bool: - """Whether this report was produced by the native rule-based conformance engine.""" + """Whether this report was produced by Pointblank's built-in rule-based conformance engine.""" return self.native_result is not None def all_passed(self) -> bool: """Whether the run reported no conformance failures. - For native reports, this is `True` when every check in every dataset passed with no failing + For built-in engine reports, this is `True` when every check in every dataset passed with no failing test units. For CORE reports, this is `True` when no rule reported an issue or execution error. """ @@ -1022,7 +1025,7 @@ def summary(self) -> dict: Returns ------- dict - For a **native** report, a mapping of dataset name to a dict with keys `n_steps`, + For a **built-in engine** report, a mapping of dataset name to a dict with keys `n_steps`, `n_steps_failed`, `n_failed` (failing test units), and `all_passed`. For a **CORE** report, a single dict with keys `standard`, `version`, @@ -1048,7 +1051,7 @@ def summary(self) -> dict: return { "standard": nr.standard, "version": nr.version, - "engine": "native", + "engine": "built-in", "ct_packages": nr.ct_packages, "n_rules": len(nr.rule_results), "status_counts": nr.status_counts(), @@ -1076,7 +1079,7 @@ def issues(self, severity: str | None = None, status: str | None = None) -> list Parameters ---------- severity - (Native reports only.) Optional severity filter: `"warning"`, `"error"`, or + (Built-in engine reports only.) Optional severity filter: `"warning"`, `"error"`, or `"critical"`. Requires thresholds to have been set on the run. If `None`, all steps with failing test units are returned. status @@ -1086,7 +1089,7 @@ def issues(self, severity: str | None = None, status: str | None = None) -> list Returns ------- list[dict] - For a **native** report, one dict per failing step, with keys `dataset`, `step`, + For a **built-in engine** report, one dict per failing step, with keys `dataset`, `step`, `assertion`, `column`, `n_failed`, `n`, and `severity`. For a **CORE** report, one dict per (dataset, rule) with reported issues, with keys @@ -1146,8 +1149,8 @@ def findings(self): """Return the row-level findings. For CORE reports, returns `CoreFinding` objects from CORE's `Issue_Details`. - For native rule reports, returns `NativeRowFinding` objects. - For Validate-based native reports, returns an empty list. + For built-in engine reports, returns `NativeRowFinding` objects. + For Validate-based reports, returns an empty list. """ if self.is_core: return list(self.core.findings) @@ -1159,14 +1162,14 @@ def rules(self, status: str | None = None): """Return the per-rule run results. For CORE reports, returns `CoreRuleResult` objects. - For native rule reports, returns `NativeRuleResult` objects. - For Validate-based native reports, returns an empty list. + For built-in engine reports, returns `NativeRuleResult` objects. + For Validate-based reports, returns an empty list. Parameters ---------- status - Optional status filter. For CORE: e.g. `"SUCCESS"`, `"SKIPPED"`. For native rules: - `"pass"`, `"fail"`, `"error"`, `"not_applicable"`, `"not_supported"`. + Optional status filter. For CORE: e.g. `"SUCCESS"`, `"SKIPPED"`. For built-in + engine reports: `"pass"`, `"fail"`, `"error"`, `"not_applicable"`, `"not_supported"`. """ if self.is_core: if status is None: @@ -1190,7 +1193,7 @@ def to_json(self, path: str | Path) -> Path: For CORE reports the output mirrors the original CORE JSON structure (`Conformance_Details`, `Dataset_Details`, `Issue_Summary`, `Issue_Details`, `Rules_Report`), making the file - readable by anything that parses a standard CORE report. For native reports the file + readable by anything that parses a standard CORE report. For built-in engine reports the file contains `summary` and `issues` keys. Parameters @@ -1259,8 +1262,8 @@ def to_excel(self, path: str | Path) -> Path: """Save the conformance report as an Excel workbook. For CORE reports the workbook contains sheets `Issue_Summary`, `Issue_Details`, - `Rules_Report`, and `Conformance_Details`. For native reports the workbook contains - `Issues` and `Summary`. + `Rules_Report`, and `Conformance_Details`. For built-in engine reports the workbook + contains `Issues` and `Summary`. Requires the `openpyxl` package (`pip install openpyxl` or `pip install 'pointblank[excel]'`). @@ -1380,8 +1383,287 @@ def to_excel(self, path: str | Path) -> Path: return dest + def findings_df(self): + """Return all row-level findings as a Polars DataFrame. + + Each row represents one failing record captured during the conformance run. Use this method + for programmatic analysis (filtering by rule, grouping by subject, exporting to CSV, or + joining back to the source datasets to investigate root causes). + + Only `RECORD_CHECK` and `DATASET_CONTENTS_CHECK` rules produce row-level findings; rules + that check metadata or domain presence (e.g., `VARIABLE_METADATA_CHECK`, + `DOMAIN_PRESENCE_CHECK`) report a finding count in `get_tabular_report()` but do not appear + here. To see the visual findings table call `get_findings_table()` instead. + + Findings are capped at **100 rows per rule** to bound memory use on large datasets. The + `n_issues` value shown in `get_tabular_report()` always reflects the true total count for a + rule, even when more than 100 records failed. + + Returns + ------- + polars.DataFrame + One row per captured finding with the following columns: + + - `rule_id`: CDISC CORE rule identifier (e.g., `"SDTM-007"`). + - `dataset`: The SDTM domain the failing record belongs to (e.g., `"AE"`). + - `row_index`: 0-based row position of the failing record in the source dataset. + - `usubjid`: Unique Subject Identifier from the `"USUBJID"` column, if present. + - `checked_column`: The specific variable that violated the rule (e.g., `"SEX"`). + - `checked_value`: The actual value of `checked_column` in that row. + - `description`: Human-readable rule description. + Derived first from the rule's operations; falls back to the conditions tree for + rules with no explicit operations (e.g., range checks like `AGE < 0`). + - `checked_value`: The actual value of `checked_column` in that row. + - `description`: Human-readable rule description. + + Returns an empty DataFrame (with the same schema) when all rules pass. + + Raises + ------ + TypeError + If called on a CDISC CORE-backed report. Use `findings()` instead, which returns a list + of `CoreFinding` objects. + """ + import polars as pl + + if not self.is_rules: + raise TypeError( + "findings_df() is only available for built-in engine results. " + "For CORE-backed reports, use findings() which returns CoreFinding objects." + ) + + rows: list[dict] = [] + for rule_result in self.native_result.rule_results: + for f in rule_result.row_findings: + rows.append( + { + "rule_id": f.rule_id, + "dataset": f.dataset, + "row_index": f.row if f.row is not None else -1, + "usubjid": f.usubjid or "", + "checked_column": f.checked_column or "", + "checked_value": f.checked_value or "", + "description": rule_result.description, + } + ) + + _SCHEMA = { + "rule_id": pl.String, + "dataset": pl.String, + "row_index": pl.Int64, + "usubjid": pl.String, + "checked_column": pl.String, + "checked_value": pl.String, + "description": pl.String, + } + if not rows: + return pl.DataFrame(schema=_SCHEMA) + return pl.DataFrame(rows, schema=_SCHEMA) + + def get_findings_table(self) -> "GT": + """Build a record-level findings table as a styled Great Tables object. + + Returns one row per failing record captured by Pointblank's built-in rules engine. This is the + drill-down companion to `get_tabular_report()`: where the tabular report shows one + row per rule with an aggregate issue count, the findings table shows the individual + offending records so reviewers can trace violations back to specific subjects and + variables. + + Table layout + ------------ + The table has two column spanners: + + - **Rule**: `Domain` and `Description` identify which rule fired and in which domain. + - **Finding**: `USUBJID`, `Column`, `Row`, and `Value` identify the specific record. + + - `USUBJID`: the unique subject identifier (e.g., `"CDISCPILOT01-01-001"`). + - `Column`: the variable that violated the rule (e.g., `"SEX"`). + - `Row`: 1-based row number of the failing record in the source domain dataset. + - `Value`: the actual value found in `Column` for that row. + + The header shows the standard and version (e.g., `SDTMIG 3-4`) alongside a breakdown of how + many rules passed, failed, and were not applicable across the full run. + + A narrow red bar on the left edge of each row marks it as a failure, consistent with the + color coding in `get_tabular_report()`. + + Findings cap + ------------ + At most 100 findings per rule are shown. When a rule has more than 100 failing records + the table shows the first 100; the true total is always visible in `get_tabular_report()`. + + Returns + ------- + GT + A styled `great_tables.GT` object. Renders automatically in Jupyter and Quarto + notebooks. + + Raises + ------ + TypeError + If called on a CDISC CORE-backed report. The findings table is only available for + built-in engine results. + ValueError + If there are no row-level findings to display (i.e., all applicable rules passed). + """ + import polars as pl + from great_tables import GT, from_column, google_font, html, loc, style + + if not self.is_rules: + raise TypeError("get_findings_table() is only available for built-in engine results.") + + df = self.findings_df() + if df.is_empty(): + raise ValueError("No row-level findings to display — all rules passed.") + + # Add a red status bar column (all findings are failures) + df = df.with_columns(pl.lit("#FF3300").alias("status_color")) + + # 1-indexed row number for easy record lookup + df = df.with_columns((pl.col("row_index") + 1).alias("row_1indexed")) + + # Reorder columns: color bar first, then rule info, then finding details + df = df.select( + [ + "status_color", + "rule_id", + "dataset", + "description", + "usubjid", + "checked_column", + "row_1indexed", + "checked_value", + ] + ) + + # Build header matching the tabular conformance report style + nr = self.native_result + counts = nr.status_counts() + _LABEL = { + "pass": "passed", + "fail": "failed", + "error": "error", + "not_applicable": "n/a", + "not_supported": "unsupported", + } + counts_parts = [] + n_failed = counts.get("fail", 0) + n_passed = counts.get("pass", 0) + if n_failed: + counts_parts.append(f"{n_failed} failed ({n_passed} passed)") + for st in ("error", "not_applicable", "not_supported"): + n = counts.get(st, 0) + if n: + counts_parts.append(f"{n} {_LABEL[st]}") + counts_str = " · ".join(counts_parts) + + title_text = "Findings Report for CDISC Conformance" + subtitle_text = f"{nr.standard.upper()} {nr.version} · {counts_str}" + + gt = ( + GT(df) + .tab_header(title=html(title_text), subtitle=html(subtitle_text)) + .tab_spanner( + label="Finding", + columns=["usubjid", "checked_column", "row_1indexed", "checked_value"], + ) + .tab_spanner( + label="SDTM Rule Definition", columns=["rule_id", "dataset", "description"] + ) + .cols_move_to_start(columns="status_color") + .cols_label( + status_color="", + rule_id="Rule", + dataset="Domain", + description="Description", + usubjid="USUBJID", + checked_column="Column", + row_1indexed="Row", + checked_value="Value", + ) + # Should be 904px wide, just like the validation report table. + .cols_width( + status_color="4px", + rule_id="70px", + dataset="70px", + description="360px", + usubjid="130px", + checked_column="100px", + row_1indexed="50px", + checked_value="120px", + ) + .tab_style( + style=style.fill(color=from_column("status_color")), + locations=loc.body(columns="status_color"), + ) + .tab_style( + style=style.text(color=from_column("status_color"), whitespace="nowrap"), + locations=loc.body(columns="status_color"), + ) + .tab_style( + style=style.text(font=google_font("IBM Plex Mono"), size="11px"), + locations=loc.body(), + ) + .tab_style( + style=style.css("padding-top: 2px; padding-bottom: 2px;"), + locations=loc.body(), + ) + .tab_style( + style=style.css("overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"), + locations=loc.body( + columns=["usubjid", "checked_column", "row_1indexed", "checked_value"] + ), + ) + .opt_table_font(font=google_font("IBM Plex Sans")) + .opt_align_table_header(align="left") + .tab_options(table_font_size="90%") + ) + + return gt + def get_tabular_report(self) -> "GT": - """Build a Great Tables conformance report for the native rules engine.""" + """Build a rule-level conformance summary table as a styled Great Tables object. + + Returns one row per rule in the catalog, summarizing whether each rule passed, failed, + was not applicable, or could not be evaluated. This is the high-level overview; use + `get_findings_table()` or `findings_df()` to drill into the individual failing records. + + Table layout + ------------ + Each row contains: + + - A colored status bar on the left edge: green for pass, red for fail, amber for error, + and grey for not-applicable or not-supported. + - ``Rule`` — CDISC CORE rule identifier (e.g., ``"SDTM-007"``). + - ``Domain`` — The SDTM domain(s) the rule targets. Rules that apply to every domain + show a comma-separated list; rules targeting all SUPP-- datasets show ``"SUPP--"``. + - ``Type`` — The rule category: ``Record``, ``Variable``, ``Metadata``, ``Domain``, + ``Dataset``, ``Define``, or ``Codelist``. + - ``Issues`` — Count of failing records or dataset-level violations. Shown in bold red + when non-zero. This count always reflects the true total, even when the findings table + caps display at 100 rows per rule. + - ``Description`` — Human-readable explanation of what the rule checks. + + Rows are sorted by severity: failing rules appear first, followed by errors, passing + rules, not-applicable rules, and unsupported rule types. + + The table header shows ``"CDISC Conformance"`` with a ``PASS`` or ``FAIL`` badge, + and a subtitle line with the standard, version, and a count breakdown + (e.g., ``SDTMIG 3-4 · 410 passed · 4 failed · 12 n/a``). + + Returns + ------- + GT + A styled `great_tables.GT` object set in IBM Plex Sans / IBM Plex Mono. Renders + automatically in Jupyter and Quarto notebooks; call `._repr_html_()` to get the + HTML string directly. This is the same object produced by `_repr_html_()`. + + Raises + ------ + TypeError + If called on a CDISC CORE-backed report. The tabular report is only available for + built-in engine results. + """ from importlib.metadata import version as _pkg_version import polars as pl @@ -1389,7 +1671,7 @@ def get_tabular_report(self) -> "GT": if not self.is_rules: raise TypeError( - "get_tabular_report() is only available for native rules results. " + "get_tabular_report() is only available for built-in engine results. " "Use validate_conformance(engine='native') to obtain one." ) @@ -1429,21 +1711,16 @@ def _fmt_dataset(ds: str) -> str: supp = [p for p in parts if p.startswith("SUPP")] other = [p for p in parts if not p.startswith("SUPP")] if len(supp) > 1: - abbrev = "SUPP--: " + ", ".join(p[4:] for p in supp) - return ", ".join(other + [abbrev]) if other else abbrev + return ", ".join(other + ["SUPP--"]) if other else "SUPP--" return ds - desc_max = 90 data = { "status_color": [_STATUS_COLORS.get(r.status, "#AAAAAA") for r in rows], "rule_id": [r.rule_id for r in rows], "dataset": [_fmt_dataset(r.dataset) for r in rows], "type": [_TYPE_LABELS.get(r.rule_type, r.rule_type) for r in rows], "n_issues": [r.n_issues for r in rows], - "description": [ - r.description[:desc_max] + "…" if len(r.description) > desc_max else r.description - for r in rows - ], + "description": [r.description for r in rows], } df = pl.DataFrame(data) @@ -1524,7 +1801,7 @@ def _fmt_dataset(ds: str) -> str: cases={ "status_color": "", "rule_id": "Rule", - "dataset": "Dataset", + "dataset": "Domain", "type": "Type", "n_issues": "Issues", "description": "Description", @@ -1606,7 +1883,7 @@ def _repr_html_(self) -> str: def __repr__(self) -> str: if self.is_rules: nr = self.native_result - lines = ["ConformanceReport (Native Rules)"] + lines = ["ConformanceReport (Built-in Rules)"] if self.agency: lines.append(f" Agency: {self.agency}") lines.append(f" {nr.standard} {nr.version}") @@ -1769,45 +2046,103 @@ def validate_sdtmig( define_xml: Any = None, study_id: str | None = None, ) -> ConformanceReport: - """Check SDTMIG conformance and return a renderable report. - - Runs the bundled SDTMIG rule catalog against the provided datasets. No external tools, - subprocesses, or network calls are required. The returned [`ConformanceReport`] renders as a - color-coded Great Tables summary. + """Validate SDTM datasets against the SDTMIG rule catalog and return a conformance report. + + Runs the bundled SDTMIG 3.4 rule catalog (426 rules) against the provided SDTM domain + datasets using Pointblank's built-in conformance engine. No external tools, subprocesses, + network calls, or CDISC CORE installation are required. + + The catalog covers seven rule types: + + - **RECORD_CHECK** — per-row value checks (controlled terminology, ISO 8601 dates, ranges, + uniqueness constraints). These rules produce row-level findings accessible via + `findings_df()` and `get_findings_table()`. + - **VARIABLE_METADATA_CHECK** — variable presence and ordering (e.g., USUBJID must appear + before domain-specific variables). + - **DATASET_METADATA_CHECK** — dataset-level attributes (sort keys, required sort order). + - **DATASET_CONTENTS_CHECK** — dataset-level value constraints (e.g., all rows in a domain + must share the same STUDYID). + - **DOMAIN_PRESENCE_CHECK** — required or prohibited domain presence (e.g., DM must be + present, RELREC must not appear in an SDTM-only package). + - **DEFINE_ITEM_METADATA_CHECK** — variable declarations in the Define-XML (activated only + when `define_xml` is supplied). + - **DEFINE_CODELIST_CHECK** — codelist declarations in the Define-XML (activated only when + `define_xml` is supplied). + + Controlled Terminology + ---------------------- + By default the most recent bundled CT package (``sdtm-ct-2024-09-27``) is used. Codelist + checks are case-insensitive: a value of ``"beats/min"`` matches a term ``"BEATS/MIN"``. + SAS/XPT missing values (empty strings ``""``) are treated as null and skipped, so they do + not generate false positives for codelist or format rules. + + SUPP-- and RELREC handling + -------------------------- + Supplemental Qualifiers (``SUPP--``) datasets use ``RDOMAIN`` instead of ``DOMAIN`` and + have a fixed non-standard structure, so they are automatically excluded from catch-all rules + (rules with no explicit domain list). RELREC is similarly excluded. Parameters ---------- datasets - Mapping of domain name to DataFrame. Keys are case-insensitive (e.g., `"DM"` or `"dm"`). - Accepts Polars, pandas, or any narwhals-supported DataFrame. + Mapping of SDTM domain name to a DataFrame. Keys are matched case-insensitively + (``"DM"`` and ``"dm"`` are equivalent). Accepts Polars, pandas, or any + narwhals-compatible DataFrame. Include all domains relevant to your submission; + rules that require a domain not in the mapping are marked ``not_applicable``. version - SDTMIG version. Currently `"3-4"` (default) is the only bundled catalog. + SDTMIG version string. Accepts either dot or hyphen notation (``"3.4"`` or ``"3-4"``). + Currently only ``"3-4"`` has a bundled catalog. ct_packages - Controlled Terminology package name(s) to load (e.g., `["sdtm-ct-2024-09-27"]`). Defaults - to the latest bundled CT package. + One or more CT package slugs to load (e.g., ``["sdtm-ct-2024-09-27"]``). When + ``None`` (the default) the most recent bundled package is used automatically. define_xml - Optional path to a `define.xml` file or a pre-parsed `MetadataPackage`. When provided, - Define-XML-aware rules (codelist declarations, mandatory variables) are activated. + Optional Define-XML metadata, supplied as a file path (``str`` or ``pathlib.Path``) or + a pre-parsed ``MetadataPackage`` object. When provided, ``DEFINE_ITEM_METADATA_CHECK`` + and ``DEFINE_CODELIST_CHECK`` rules become active; without it they are marked + ``not_applicable``. study_id - Optional study identifier shown in the report header. + Optional study identifier (e.g., ``"CDISCPILOT01"``) shown in the report header. Returns ------- ConformanceReport - A native-rules report (`is_rules` is `True`). Displays as a Great Tables table in - notebooks; call `.get_tabular_report()` to get the `GT` object directly. + A built-in engine report (``is_rules`` is ``True``). In Jupyter and Quarto notebooks the + object renders automatically as the rule-level summary table. Call + ``get_tabular_report()`` for the `GT` object, ``get_findings_table()`` for a + record-level drill-down, or ``findings_df()`` for a Polars DataFrame of failing rows. Examples -------- + Validate a study from in-memory Polars DataFrames: + + ```python + import pointblank as pb + + report = pb.validate_sdtmig({"DM": dm, "AE": ae, "LB": lb}) + report # renders the rule summary table in a notebook + ``` + + Drill down to the individual failing records: + + ```python + report.get_findings_table() # styled record-level table + report.findings_df() # Polars DataFrame for programmatic use + ``` + + Load from XPT files using pyreadstat: + ```python - import polars as pl - from pointblank.metadata import validate_sdtmig + import pyreadstat, polars as pl - dm = pl.read_parquet("sdtm/dm.parquet") - ae = pl.read_parquet("sdtm/ae.parquet") + def load(path): + df, _ = pyreadstat.read_xport(path) + return pl.from_pandas(df) - report = validate_sdtmig({"DM": dm, "AE": ae}) - report + report = pb.validate_sdtmig({ + "DM": load("sdtm/dm.xpt"), + "AE": load("sdtm/ae.xpt"), + "LB": load("sdtm/lb.xpt"), + }, study_id="STUDY001") ``` """ From 5627433864419bce456c0bc602d9eb0f7995dac5 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 17:36:48 -0400 Subject: [PATCH 77/93] Update test_cdisc_core.py --- tests/test_cdisc_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cdisc_core.py b/tests/test_cdisc_core.py index 3f17d3d67..363f3cec0 100644 --- a/tests/test_cdisc_core.py +++ b/tests/test_cdisc_core.py @@ -708,7 +708,7 @@ def test_to_json_native(tmp_path): assert "summary" in data assert "issues" in data s = data["summary"] - assert s["engine"] == "native" + assert s["engine"] == "built-in" assert "standard" in s assert "n_rules" in s From 0cd74f156fe6f6e846b539fddd96f19fb313888c Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 17:37:07 -0400 Subject: [PATCH 78/93] Add findings_df() and get_findings_table() smoke tests --- tests/test_cdiscpilot01_smoke.py | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_cdiscpilot01_smoke.py b/tests/test_cdiscpilot01_smoke.py index 7e94c7236..0a25f1a6d 100644 --- a/tests/test_cdiscpilot01_smoke.py +++ b/tests/test_cdiscpilot01_smoke.py @@ -339,3 +339,50 @@ def test_get_tabular_report_returns_gt(pilot_report): def test_repr_html_non_empty(pilot_report): html = pilot_report._repr_html_() assert html and len(html) > 500 + + +# ── findings_df and get_findings_table ──────────────────────────────────────── + + +def test_findings_df_schema(pilot_report): + """findings_df() returns a DataFrame with the expected column set.""" + import polars as pl + + df = pilot_report.findings_df() + assert isinstance(df, pl.DataFrame) + expected = {"rule_id", "dataset", "row_index", "usubjid", "checked_column", "checked_value", "description"} + assert expected.issubset(set(df.columns)) + + +def test_findings_df_has_usubjid_values(pilot_report): + """Every finding must carry a non-empty USUBJID (pilot DM has 306 subjects).""" + df = pilot_report.findings_df() + # All captured row findings (capped at 100 per rule) should have a USUBJID + assert (df["usubjid"] != "").all(), "Some findings are missing USUBJID" + + +def test_findings_df_aerel_findings(pilot_report): + """findings_df() for SDTM-135 (AEREL) must capture checked_column='AEREL'.""" + import polars as pl + + df = pilot_report.findings_df() + aerel = df.filter(pl.col("rule_id") == "SDTM-135") + assert len(aerel) > 0 + assert (aerel["checked_column"] == "AEREL").all() + # checked_value should be one of the old CT terms + old_terms = {"PROBABLE", "POSSIBLE", "REMOTE", "NONE"} + actual_vals = set(aerel["checked_value"].unique().to_list()) + assert actual_vals & old_terms, f"Expected old AEREL terms, got {actual_vals}" + + +def test_get_findings_table_renders(pilot_report): + """get_findings_table() renders a non-trivial HTML string.""" + try: + from great_tables import GT + except ImportError: + pytest.skip("great_tables not installed") + gt = pilot_report.get_findings_table() + assert isinstance(gt, GT) + html = gt._repr_html_() + assert len(html) > 1000 + assert "SDTM-" in html # at least one rule ID visible From 71493e575a690d66ede1517b0dcd942ecf12583d Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 17:37:21 -0400 Subject: [PATCH 79/93] Expand conformance finding tests --- tests/test_native_conformance.py | 123 ++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 4 deletions(-) diff --git a/tests/test_native_conformance.py b/tests/test_native_conformance.py index e4fa0596d..8bf86e0d5 100644 --- a/tests/test_native_conformance.py +++ b/tests/test_native_conformance.py @@ -491,6 +491,47 @@ def test_engine_row_findings_populated(engine): assert f.row == 0 +def test_engine_row_finding_has_usubjid(engine): + dm = pl.DataFrame( + {"STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], "SUBJID": ["1"], "SEX": ["Q"]} + ) + result = engine.run({"DM": dm}) + findings = result.findings() + sex_finding = next(f for f in findings if f.rule_id == "SDTM-007") + assert sex_finding.usubjid == "U1" + assert sex_finding.checked_column == "SEX" + assert sex_finding.checked_value == "Q" + + +def test_engine_row_finding_has_date_column(engine): + dm = pl.DataFrame( + { + "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], "SUBJID": ["1"], + "SEX": ["M"], "RACE": ["WHITE"], "ETHNIC": ["NOT HISPANIC OR LATINO"], + "COUNTRY": ["USA"], "ARMCD": ["A"], "ARM": ["Arm A"], + "ACTARMCD": ["A"], "ACTARM": ["Arm A"], + "DMDTC": ["not-a-date"], + } + ) + result = engine.run({"DM": dm}) + findings = result.findings() + date_finding = next((f for f in findings if f.rule_id == "SDTM-010"), None) + assert date_finding is not None + assert date_finding.checked_column == "DMDTC" + assert date_finding.checked_value == "not-a-date" + assert date_finding.usubjid == "U1" + + +def test_engine_row_finding_no_usubjid_when_absent(engine): + # Dataset without USUBJID + dm = pl.DataFrame({"STUDYID": ["S1"], "DOMAIN": ["DM"], "SEX": ["Q"]}) + result = engine.run({"DM": dm}) + findings = result.findings() + sex_finding = next((f for f in findings if f.rule_id == "SDTM-007"), None) + if sex_finding is not None: + assert sex_finding.usubjid is None + + def test_engine_rules_status_filter(clean_result): passing = clean_result.rules(status=STATUS_PASS) assert all(r.status == STATUS_PASS for r in passing) @@ -536,7 +577,7 @@ def test_submission_package_summary_has_engine_key(): pkg = pb.SubmissionPackage(datasets={"DM": _clean_dm()}) report = pkg.validate_conformance() s = report.summary() - assert s["engine"] == "native" + assert s["engine"] == "built-in" assert "n_rules" in s assert "n_issues" in s @@ -551,11 +592,11 @@ def test_submission_package_dirty_data_fails(): assert len(report.issues()) > 0 -def test_submission_package_repr_shows_native_rules(): +def test_submission_package_repr_shows_built_in_rules(): pkg = pb.SubmissionPackage(datasets={"DM": _clean_dm()}) report = pkg.validate_conformance() r = repr(report) - assert "Native Rules" in r + assert "Built-in Rules" in r def test_submission_package_to_json_rules(tmp_path): @@ -563,7 +604,7 @@ def test_submission_package_to_json_rules(tmp_path): report = pkg.validate_conformance() dest = report.to_json(tmp_path / "r.json") data = json.loads(dest.read_text()) - assert data["summary"]["engine"] == "native" + assert data["summary"]["engine"] == "built-in" assert isinstance(data["issues"], list) @@ -599,6 +640,80 @@ def test_submission_package_findings_accessor(): assert all(isinstance(f, NativeRowFinding) for f in findings) +def test_findings_df_returns_dataframe(): + import polars as pl + + dirty = pl.DataFrame({ + "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], "SEX": ["BAD"] + }) + report = pb.validate_sdtmig({"DM": dirty}) + df = report.findings_df() + assert isinstance(df, pl.DataFrame) + expected_cols = {"rule_id", "dataset", "row_index", "usubjid", "checked_column", "checked_value", "description"} + assert expected_cols.issubset(set(df.columns)) + assert len(df) > 0 + + +def test_findings_df_captures_correct_fields(): + import polars as pl + + dirty = pl.DataFrame({ + "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U99"], + "SUBJID": ["99"], "SEX": ["Q"], + }) + report = pb.validate_sdtmig({"DM": dirty}) + df = report.findings_df() + sex_row = df.filter(pl.col("rule_id") == "SDTM-007") + assert len(sex_row) == 1 + assert sex_row["usubjid"][0] == "U99" + assert sex_row["checked_column"][0] == "SEX" + assert sex_row["checked_value"][0] == "Q" + + +def test_findings_df_empty_when_all_pass(clean_result): + """findings_df() returns an empty DataFrame (correct schema) when no issues exist.""" + import polars as pl + + report = pb.ConformanceReport(native_result=clean_result) + df = report.findings_df() + assert isinstance(df, pl.DataFrame) + assert len(df) == 0 + assert "rule_id" in df.columns + + +def test_findings_df_raises_for_core_report(): + """findings_df() raises TypeError on a CORE-backed report.""" + from pointblank.metadata._cdisc_core import ParsedCoreReport + + report = pb.ConformanceReport(core=ParsedCoreReport()) + with pytest.raises(TypeError, match="findings_df"): + report.findings_df() + + +def test_get_findings_table_returns_gt(): + """get_findings_table() returns a GT object.""" + import polars as pl + from great_tables import GT + + dirty = pl.DataFrame({ + "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], + "SUBJID": ["1"], "SEX": ["Q"], + }) + report = pb.validate_sdtmig({"DM": dirty}) + gt = report.get_findings_table() + assert isinstance(gt, GT) + html = gt._repr_html_() + assert "SDTM-007" in html + assert "U1" in html + + +def test_get_findings_table_raises_when_no_findings(clean_result): + """get_findings_table() raises ValueError when there are no findings.""" + report = pb.ConformanceReport(native_result=clean_result) + with pytest.raises(ValueError, match="No row-level findings"): + report.get_findings_table() + + # ── Phase 2: JSONata evaluator ──────────────────────────────────────────────── from pointblank.metadata._conformance.jsonata import ( From 534940190f4de24aa84810020454e08cb8989f73 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 17:42:58 -0400 Subject: [PATCH 80/93] Revise SDTM conformance user guide workflow --- .../04-cdisc-submission-conformance.qmd | 682 +++++++----------- 1 file changed, 259 insertions(+), 423 deletions(-) diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd index 05f7230f2..6abb2c2a7 100644 --- a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -12,51 +12,27 @@ pb.config(report_incl_footer_timings=False) ``` Regulatory submissions require that every dataset in a study package passes CDISC conformance -rules, not just individual structural checks on each domain in isolation. The submission as a -whole must be internally consistent: every subject in an Adverse Events domain must appear in -Demographics, every Supplemental Qualifier must resolve to a record in its parent domain, every -ADaM dataset must trace back to ADSL, and several hundred rule engine checks must clear before -FDA or PMDA will accept the package. +rules. Pointblank provides a built-in conformance engine that checks your SDTM datasets against the +full SDTMIG rule catalog with no external dependencies (no subprocess, no CLI, no network). For +those preparing a final submission gate, Pointblank can also delegate to the external +[CDISC CORE engine](https://github.com/cdisc-org/cdisc-rules-engine). -Pointblank addresses this through the `SubmissionPackage` model, which treats the entire study -as a graph of related datasets and runs conformance checks across that graph. Two validation -engines are available: +Two engines are available: -- **Native**: Pointblank's own cross-dataset checks for referential integrity, SUPP-- linkage, - RELREC resolution, and ADaM traceability. No external dependencies, runs in-process. +- **Built-in** (primary): Pointblank's own engine runs 426 SDTMIG 3.4 rules against your datasets +in-process. Results are immediately renderable in a notebook. No installation beyond Pointblank is +required. +- **CORE** (advanced): delegates to the open-source CDISC CORE CLI, which runs the authoritative +CDISC-certified rule set. Requires the CORE executable to be installed separately. -- **CORE**: Delegates to the open-source - [CDISC CORE engine](https://github.com/cdisc-org/cdisc-rules-engine), which runs the - authoritative CDISC conformance rule set (430+ rules for SDTMIG 3.4). Requires the CORE - executable to be installed separately; Pointblank invokes it as a subprocess and ingests its - results. +## SDTMIG Conformance -You can use both in the same workflow: native checks for fast feedback during development, CORE -for the final pre-submission gate. +### Quick Start -## Prerequisites - -The `SubmissionPackage` and `ConformanceReport` classes require no additional dependencies -beyond Pointblank itself. If you want to: - -- Read XPT files from a folder with `from_folder()`: install `pyreadstat` -- Materialize in-memory DataFrames to XPT for the CORE engine: install `pyreadstat` -- Export a report to Excel with `to_excel()`: install `openpyxl` - -```bash -pip install pointblank[cdisc-core] # adds pyreadstat -pip install pointblank[excel] # adds openpyxl -``` - -The CORE engine itself is not a Python dependency of Pointblank and must be installed -separately. See [Installing the CDISC CORE Engine](#installing-the-cdisc-core-engine) below. - -## Native Cross-Dataset Validation - -### Building a Submission Package - -A `SubmissionPackage` groups the datasets of a study and understands the relationships between -them. You construct one from a dictionary of domain names to DataFrames: +[`validate_sdtmig()`](`pointblank.validate_sdtmig`) is the entry point for SDTMIG conformance. +Pass a dictionary mapping domain names to DataFrames and it returns a +[`ConformanceReport`](`pointblank.ConformanceReport`) that renders as a color-coded summary table +in Jupyter and Quarto notebooks: ```{python} import polars as pl @@ -65,264 +41,278 @@ import pointblank as pb dm = pl.DataFrame({ "STUDYID": ["STUDY01"] * 4, "DOMAIN": ["DM"] * 4, - "USUBJID": ["STUDY01-001", "STUDY01-002", "STUDY01-003", "STUDY01-004"], + "USUBJID": ["STUDY01-01-001", "STUDY01-01-002", "STUDY01-01-003", "STUDY01-01-004"], "SUBJID": ["001", "002", "003", "004"], - "ARMCD": ["A", "B", "A", "B"], - "ARM": ["Arm A", "Arm B", "Arm A", "Arm B"], + "RFSTDTC": ["2024-01-15", "2024-01-16", "2024-01-17", "2024-01-18"], "SEX": ["M", "F", "M", "F"], "RACE": ["WHITE", "ASIAN", "WHITE", "BLACK OR AFRICAN AMERICAN"], + "ETHNIC": ["NOT HISPANIC OR LATINO"] * 4, + "ARMCD": ["TRT", "PBO", "TRT", "PBO"], + "ARM": ["Treatment", "Placebo", "Treatment", "Placebo"], "COUNTRY": ["USA"] * 4, + "AGE": [45, 62, 38, 55], + "AGEU": ["YEARS"] * 4, + "DMDTC": ["2024-01-10"] * 4, + "DMDY": [1] * 4, + "SITEID": ["01"] * 4, }) -ae = pl.DataFrame({ - "STUDYID": ["STUDY01"] * 3, - "DOMAIN": ["AE"] * 3, - "USUBJID": ["STUDY01-001", "STUDY01-002", "STUDY01-003"], - "AESEQ": [1, 1, 1], - "AETERM": ["Headache", "Nausea", "Dizziness"], - "AESEV": ["MILD", "MODERATE", "MILD"], -}) - -study = pb.SubmissionPackage( - datasets={"DM": dm, "AE": ae}, - standard="sdtmig", - standard_version="3.4", - study_id="STUDY01", -) - -print(study) +report = pb.validate_sdtmig({"DM": dm}) +report ``` -Dataset names are normalized to uppercase internally. You can access datasets by name and -query the subject graph: +Domain keys are matched case-insensitively (`"dm"` and `"DM"` are equivalent). Polars, pandas, and +any other narwhals-compatible DataFrame are accepted. -```{python} -print("Domains:", study.domains) -print("Subjects in DM:", study.subject_ids("DM")) +### The Conformance Report -# Check whether a domain is present -print("Has AE:", "AE" in study) -print("Has LB:", "LB" in study) +The tabular report shown above is produced by +[`get_tabular_report()`](`pointblank.ConformanceReport.get_tabular_report`). Each row is one rule +from the SDTMIG 3.4 catalog; the colored bar on the left edge encodes its status: -# Find subjects in AE that are not in DM (should be empty for clean data) -print("Orphan USUBJIDs:", study.orphan_ids("AE", parent="DM")) -``` +| Color | Status | Meaning | +|---|---|---| +| Green | `pass` | The rule was evaluated and no violations were found | +| Red | `fail` | One or more records or datasets violated the rule | +| Amber | `error` | The rule could not be evaluated (unexpected data state) | +| Grey | `not_applicable` | The rule requires a dataset or variable that was not supplied | +| Grey | `not_supported` | The rule type is not yet implemented in the built-in engine | + +Rules are sorted by severity: failures first, then errors, then passes, then not-applicable. -### Running Native Conformance Validation +The header shows a `PASS` or `FAIL` badge alongside the standard, version, and a full status +breakdown (e.g., `SDTMIG 3-4 · 410 passed · 4 failed · 12 n/a`). -Calling `validate_conformance()` without any arguments runs the native engine. For each domain -it builds a `Validate` plan combining SDTM structural checks with cross-dataset consistency -checks, then interrogates them all at once: +Call `get_tabular_report()` to get the `GT` object directly if you need to embed it in a report +pipeline: ```{python} -report = study.validate_conformance() -print(report) +#| eval: false +gt = report.get_tabular_report() +gt ``` -When every check passes the report shows `PASS` for each domain. The `all_passed()` method -gives you a single boolean for use in scripts and pipelines: +### Findings Drill-Down + +When rules fail, the tabular report shows how many records violated each rule but not which ones. +[`get_findings_table()`](`pointblank.ConformanceReport.get_findings_table`) provides that +drill-down: one row per failing record, showing the subject, the specific column that violated the +rule, the offending value, and the 1-based row number in the source dataset. + +To see it in action, introduce a few deliberate violations: ```{python} -print("Passed:", report.all_passed()) -``` +dm_with_issues = pl.DataFrame({ + "STUDYID": ["STUDY01"] * 4, + "DOMAIN": ["DM"] * 4, + "USUBJID": ["STUDY01-01-001", "STUDY01-01-002", "STUDY01-01-003", "STUDY01-01-004"], + "SUBJID": ["001", "002", "003", "004"], + "RFSTDTC": ["2024-01-15", "2024-01-16", "2024-01-17", "2024-01-18"], + "SEX": ["M", "F", "UNKNOWN", "F"], # "UNKNOWN" is not in the SEX codelist + "RACE": ["WHITE", "ASIAN", "WHITE", "BLACK OR AFRICAN AMERICAN"], + "ETHNIC": ["NOT HISPANIC OR LATINO"] * 4, + "ARMCD": ["TRT", "PBO", "TRT", "PBO"], + "ARM": ["Treatment", "Placebo", "Treatment", "Placebo"], + "COUNTRY": ["USA"] * 4, + "AGE": [45, -5, 38, 55], # -5 violates AGE >= 0 + "AGEU": ["YEARS"] * 4, + "DMDTC": ["2024-01-10", "not-a-date", "2024-01-10", "2024-01-10"], # invalid ISO date + "DMDY": [1] * 4, + "SITEID": ["01"] * 4, +}) -### What the Cross-Dataset Checks Cover +report_with_issues = pb.validate_sdtmig({"DM": dm_with_issues}) +report_with_issues.get_findings_table() +``` -The native engine adds the following checks automatically when the relevant datasets are -present: +The findings table groups its columns into two spanners: -| Check | Condition | -|---|---| -| `USUBJID` referential integrity | Every domain with `USUBJID` is checked against `DM.USUBJID` | -| SUPP-- `RDOMAIN` present | The referenced parent domain must exist in the package | -| SUPP-- `USUBJID` in DM | Supplemental rows must link to a known subject | -| SUPP-- `IDVAR/IDVARVAL` resolves | The sequence link must match a record in the parent domain | -| RELREC `RDOMAIN` present | Every related-records row references a present domain | -| ADSL traces to DM | `ADSL.USUBJID` must be a subset of `DM.USUBJID` | -| ADaM traces to ADSL | Every other ADaM dataset's subjects must appear in ADSL | +- **Rule**: `Domain` and `Description` identify which rule fired and where. +- **Finding**: `USUBJID` identifies the subject; `Column` and `Value` show exactly what was wrong; +`Row` is the 1-based row number in the source dataset for quick lookup. -Each check is a `specially()` step in the per-dataset `Validate` plan, so the standard -Pointblank failure drill-down workflow applies: you can see exactly which rows failed and why. +At most 100 findings per rule are shown in the table. The true total for each rule is always visible +in `get_tabular_report()`. -### Catching Referential Integrity Problems +### Programmatic Access with findings_df() -Adding a subject who is not in DM to an AE domain is one of the most common conformance -errors. The native engine catches it immediately: +[`findings_df()`](`pointblank.ConformanceReport.findings_df`) returns the same findings as a Polars +DataFrame, which is better suited for filtering, grouping, exporting to CSV, or joining back to the +source data: ```{python} -# Subject STUDY01-999 appears in AE but not in DM -ae_with_orphan = pl.DataFrame({ - "STUDYID": ["STUDY01"] * 3, - "DOMAIN": ["AE"] * 3, - "USUBJID": ["STUDY01-001", "STUDY01-002", "STUDY01-999"], # 999 is not in DM - "AESEQ": [1, 1, 1], - "AETERM": ["Headache", "Nausea", "Dizziness"], -}) +df = report_with_issues.findings_df() +df +``` -study_with_orphan = pb.SubmissionPackage( - datasets={"DM": dm, "AE": ae_with_orphan}, - study_id="STUDY01", -) +The DataFrame schema is: -report = study_with_orphan.validate_conformance() -print("Passed:", report.all_passed()) +| Column | Description | +|---|---| +| `rule_id` | CDISC CORE rule identifier (e.g., `"SDTM-007"`) | +| `dataset` | SDTM domain the failing record belongs to | +| `row_index` | 0-based row position in the source dataset | +| `usubjid` | Unique Subject Identifier (`USUBJID`) for the failing record | +| `checked_column` | The variable that violated the rule (e.g., `"SEX"`) | +| `checked_value` | The actual value found in `checked_column` | +| `description` | Human-readable rule description | -# Drill into the specific issue -for issue in report.issues(): - print(f" [{issue['dataset']}] step {issue['step']}: " - f"{issue['n_failed']} failing row(s)") -``` +```{python} +import polars as pl -You can reach into the per-dataset `Validate` object directly to inspect individual steps: +# Filter to a specific rule +df.filter(pl.col("rule_id") == "SDTM-007") +``` ```{python} -ae_validation = report["AE"] -for step in ae_validation.validation_info: - if step.n_failed: - print(f" Check: {step.brief}") - print(f" Failed rows: {step.n_failed}") +# Group by rule to count violations +df.group_by("rule_id").agg(pl.len().alias("n_violations")).sort("n_violations", descending=True) ``` -### SUPP-- Linkage Checks +The DataFrame is empty (with the same schema) when all rules pass, so it is safe to call +unconditionally in a script. -Supplemental Qualifiers datasets (`SUPP--`) add non-standard variables to a parent domain. The -native engine checks three things: the `RDOMAIN` column must reference a domain that exists in -the package, the `USUBJID` must appear in DM, and the `IDVAR/IDVARVAL` combination must resolve -to a record in the parent domain. +### What the Rule Catalog Covers -```{python} -suppae = pl.DataFrame({ - "STUDYID": ["STUDY01"], - "RDOMAIN": ["AE"], # must match a present domain - "USUBJID": ["STUDY01-001"], # must appear in DM - "IDVAR": ["AESEQ"], # column name in the parent AE dataset - "IDVARVAL": ["1"], # value of that column; resolves to row 1 of AE - "QNAM": ["AEACN"], - "QLABEL": ["Action Taken"], - "QVAL": ["DOSE REDUCED"], -}) +The bundled SDTMIG 3.4 catalog contains 426 rules across seven types: -study_with_supp = pb.SubmissionPackage( - datasets={"DM": dm, "AE": ae, "SUPPAE": suppae}, - study_id="STUDY01", -) +| Type | Description | Examples | +|---|---|---| +| `RECORD_CHECK` | Per-row value checks | Codelist membership, ISO 8601 dates, numeric ranges | +| `VARIABLE_METADATA_CHECK` | Variable presence and column ordering | USUBJID must precede domain-specific variables | +| `DATASET_METADATA_CHECK` | Dataset-level attributes | Required sort key order | +| `DATASET_CONTENTS_CHECK` | Dataset-level value constraints | All rows in a domain must share the same STUDYID | +| `DOMAIN_PRESENCE_CHECK` | Required or prohibited domain presence | DM must be present | +| `DEFINE_ITEM_METADATA_CHECK` | Variable declarations against Define-XML | Activated when `define_xml` is supplied | +| `DEFINE_CODELIST_CHECK` | Codelist values against Define-XML | Activated when `define_xml` is supplied | -report = study_with_supp.validate_conformance() -print("Passed:", report.all_passed()) -``` +Only `RECORD_CHECK` and `DATASET_CONTENTS_CHECK` rules produce row-level findings accessible via +`findings_df()` and `get_findings_table()`. The other types report a violation count in +`get_tabular_report()` but do not have individual record detail. -### ADaM Traceability +Rules that require a domain or variable not present in your datasets are automatically marked +`not_applicable` (they are not counted as failures). For example, a rule that checks `AESTDTC` in +the AE domain is `not_applicable` when no AE dataset is supplied. -For ADaM packages the native engine checks that every subject in ADSL traces to a record in DM, -and that every other ADaM dataset's subjects trace to ADSL. These checks implement the -"derivable from SDTM" principle at the subject level: +### Controlled Terminology -```{python} -adsl = pl.DataFrame({ - "STUDYID": ["STUDY01"] * 4, - "USUBJID": ["STUDY01-001", "STUDY01-002", "STUDY01-003", "STUDY01-004"], - "SUBJID": ["001", "002", "003", "004"], - "TRT01P": ["Arm A", "Arm B", "Arm A", "Arm B"], - "TRT01A": ["Arm A", "Arm B", "Arm A", "Arm B"], - "AGE": [45, 62, 38, 55], - "SEX": ["M", "F", "M", "F"], - "RACE": ["WHITE", "ASIAN", "WHITE", "BLACK OR AFRICAN AMERICAN"], - "COUNTRY": ["USA"] * 4, - "SAFFL": ["Y", "Y", "Y", "Y"], - "ITTFL": ["Y", "Y", "Y", "Y"], -}) +Codelist checks use the bundled CT package `sdtm-ct-2024-09-27`. Two important behaviors: -adae = pl.DataFrame({ - "STUDYID": ["STUDY01", "STUDY01"], - "USUBJID": ["STUDY01-001", "STUDY01-002"], - "AESEQ": [1, 1], - "AETERM": ["Headache", "Nausea"], - "TRTEMFL": ["Y", "Y"], -}) +- **Case-insensitive matching**: a value of `"beats/min"` matches the codelist term `"BEATS/MIN"`. +This avoids false positives for studies that applied mixed-case CT values. +- **SAS/XPT empty strings treated as null**: SAS Transport files encode character missing values as +`""` (empty string) rather than `None`. The engine recognizes this and skips codelist and format +checks for such cells, preventing the large volumes of false positives that occur with a naive +string comparison. -adam_study = pb.SubmissionPackage( - datasets={"DM": dm, "ADSL": adsl, "ADAE": adae}, - standard="adamig", - standard_version="1.1", - study_id="STUDY01", -) +### Supply a Custom CT Package -report = adam_study.validate_conformance() -print("Passed:", report.all_passed()) +By default the most recent bundled CT package is used. Pass `ct_packages` to pin a specific version +or supply additional packages: + +```{python} +#| eval: false +report = pb.validate_sdtmig( + {"DM": dm}, + ct_packages=["sdtm-ct-2024-09-27"], +) ``` -### Disabling Cross-Dataset Checks +### Activating Define-XML Rules -Pass `cross_dataset=False` to run only the per-dataset structural checks without any graph -traversal. This is useful when you want to understand the structural baseline before adding the -relational rules: +Pass a path to `define.xml` to activate the Define-XML-aware rule types: ```{python} -report = study_with_orphan.validate_conformance(cross_dataset=False) -# The orphan subject is not flagged because referential checks are disabled -print("Passed (structural only):", report.all_passed()) +#| eval: false +report = pb.validate_sdtmig( + {"DM": dm, "AE": ae}, + define_xml="path/to/define.xml", +) ``` -## Ingesting a Folder of Datasets +Without `define_xml`, the 76 `DEFINE_ITEM_METADATA_CHECK` and `DEFINE_CODELIST_CHECK` rules are +marked `not_applicable`. + +### Loading XPT Files -If your study datasets live on disk as XPT files, use `from_folder()` to read them all at once. -Pointblank reads every `.xpt` file in the folder and derives the domain name from the file stem. -If a `define.xml` is present it is picked up automatically: +For datasets stored as SAS Transport (XPT) files, use `pyreadstat` to load them before passing to +`validate_sdtmig()`. Install it with `pip install pyreadstat`: ```{python} #| eval: false -study = pb.SubmissionPackage.from_folder( - "path/to/sdtm/", - standard="sdtmig", - standard_version="3.4", +import pyreadstat +import polars as pl +import pointblank as pb + +def load_xpt(path: str) -> pl.DataFrame: + df, _ = pyreadstat.read_xport(path) + return pl.from_pandas(df) + +report = pb.validate_sdtmig( + { + "DM": load_xpt("sdtm/dm.xpt"), + "AE": load_xpt("sdtm/ae.xpt"), + "LB": load_xpt("sdtm/lb.xpt"), + "VS": load_xpt("sdtm/vs.xpt"), + }, study_id="STUDY01", ) +``` + +Pointblank handles the SAS empty-string convention automatically, so XPT data does not require any +preprocessing before being passed to `validate_sdtmig()`. + +## Prerequisites + +`validate_sdtmig()` and the `ConformanceReport` methods require no additional dependencies beyond +Pointblank itself. The optional extras are: + +- **Reading XPT files** with `pyreadstat`: `pip install pyreadstat` +- **Writing XPT files** for CORE (automatic, handled internally): `pip install pyreadstat` +- **Excel export** with `to_excel()`: `pip install openpyxl` -report = study.validate_conformance() +```bash +pip install pointblank[cdisc] # adds pyreadstat +pip install pointblank[excel] # adds openpyxl ``` -Dataset-JSON (`.json`) files in the Dataset-JSON 1.1 format are also supported alongside XPT. -Pointblank reads both formats from the same folder. +The CDISC CORE engine is not a Python dependency of Pointblank and must be installed separately when +needed. See [Installing the CDISC CORE Engine](#installing-the-cdisc-core-engine) below. + +## CDISC CORE Engine (Advanced) -## Installing the CDISC CORE Engine +The CDISC CORE engine runs the full authoritative conformance rule set and produces reports accepted +by FDA and PMDA review tools. Use it as the final pre-submission gate after the built-in engine +gives a clean result. -The CDISC CORE engine runs the full authoritative conformance rule set. It is developed and -maintained by CDISC as open-source software. Pointblank invokes it as an external subprocess; -the engine is intentionally not a Python dependency so that version constraints do not conflict. +### Installing the CDISC CORE Engine -### Option 1: Standalone Executable +#### Option 1: Standalone Executable Download the pre-built standalone executable from the -[CDISC CORE releases page](https://github.com/cdisc-org/cdisc-rules-engine/releases). -The executable bundles the Python runtime and rules cache and requires no other installation. -Place it somewhere on your `PATH` under the name `core`: +[CDISC CORE releases page](https://github.com/cdisc-org/cdisc-rules-engine/releases). Place it +somewhere on your `PATH` under the name `core`: ```bash -# On macOS/Linux, after downloading and making it executable: chmod +x core sudo mv core /usr/local/bin/ - -# Verify: core --version ``` -### Option 2: Docker +#### Option 2: Docker CDISC publishes an official Docker image that includes the engine and its full rules cache: ```bash docker pull cdisc/cdisc-rules-engine:latest -# Run a validation (bind-mount your data directory): docker run --rm \ -v /path/to/study/data:/data \ cdisc/cdisc-rules-engine:latest \ validate -s sdtmig -v 3-4 -d /data -of JSON -o /data/report ``` -### Option 3: Repo Checkout - -For development or to inspect CORE's source: +#### Option 3: Repo Checkout ```bash git clone https://github.com/cdisc-org/cdisc-rules-engine.git @@ -331,8 +321,7 @@ pip install -r requirements.txt python core.py --version ``` -When using a repo checkout, CORE resolves its bundled rules cache relative to its current -working directory. You must pass the repo root as `core_cwd` so Pointblank sets the subprocess +When using a repo checkout, pass the repo root as `core_cwd` so Pointblank sets the subprocess working directory correctly. ### Telling Pointblank Where to Find CORE @@ -340,35 +329,28 @@ working directory correctly. Pointblank discovers CORE through three mechanisms, tried in order: 1. An explicit `core=` argument to `validate_cdisc_submission()` or - `validate_conformance(engine="core")`. -2. The `POINTBLANK_CDISC_CORE` environment variable. Set this to the path of the executable or - a full command prefix such as `"python /path/to/core.py"`. +`validate_conformance(engine="core")`. +2. The `POINTBLANK_CDISC_CORE` environment variable (e.g., +`export POINTBLANK_CDISC_CORE="python /path/to/core.py"`). 3. A `core` or `cdisc-rules-engine` executable on `PATH`. -## Delegating to the CDISC CORE Engine - -### The One-Call Entry Point +### Running CORE -`validate_cdisc_submission()` is the simplest way to run a CORE validation. It accepts an -in-memory dictionary of DataFrames, a folder path, or an existing `SubmissionPackage`: +`validate_cdisc_submission()` is the simplest way to run a CORE validation. It accepts an in-memory +dictionary of DataFrames, a folder path, or an existing `SubmissionPackage`: ```{python} #| eval: false -import polars as pl -import pointblank as pb - -dm = pl.DataFrame({...}) # your DM dataset - report = pb.validate_cdisc_submission( - {"DM": dm}, + {"DM": dm, "AE": ae}, standard="sdtmig", version="3.4", + agency="FDA", ) - -print(report) ``` -For a folder of XPT files: +For a folder of XPT files pass the path directly. Pointblank skips the materialization step and +passes the folder straight to CORE: ```{python} #| eval: false @@ -380,47 +362,7 @@ report = pb.validate_cdisc_submission( ) ``` -### Using SubmissionPackage.validate_conformance() - -The same result with more control: build the package first, then choose the engine: - -```{python} -#| eval: false -study = pb.SubmissionPackage( - datasets={"DM": dm, "AE": ae}, - standard="sdtmig", - standard_version="3.4", - study_id="STUDY01", -) - -# Run the full CDISC CORE rule set -core_report = study.validate_conformance( - engine="core", - agency="FDA", - controlled_terminology="sdtmct-2024-03-29", -) -``` - -### How In-Memory Datasets Reach CORE - -When you pass DataFrames directly, Pointblank materializes them to SAS Transport (XPT) files in -a temporary working directory, runs CORE against that directory, and then cleans up. For packages -read with `from_folder()`, Pointblank skips the materialization step and passes the on-disk -folder directly to CORE, which is faster and avoids any XPT conversion overhead. - -You can pin the working directory to avoid the cleanup and inspect the materialized files: - -```{python} -#| eval: false -report = study.validate_conformance( - engine="core", - workdir="/tmp/my_core_run", # not cleaned up; inspect dm.xpt, ae.xpt, core_report.json -) -``` - -### Repo-Checkout Invocation - -When using a CORE repo checkout, pass the command prefix and repo root: +For repo-checkout CORE, pass the command prefix and working directory: ```{python} #| eval: false @@ -434,31 +376,16 @@ report = pb.validate_cdisc_submission( ) ``` -Or configure once with environment variables and then call without any extra arguments: +### Working with a CORE ConformanceReport -```bash -export POINTBLANK_CDISC_CORE="python /path/to/core.py" -export POINTBLANK_CDISC_CORE_CWD="/path/to/cdisc-rules-engine" -``` - -```{python} -#| eval: false -# Pointblank reads POINTBLANK_CDISC_CORE from the environment automatically -report = pb.validate_cdisc_submission({"DM": dm}, standard="sdtmig", version="3.4") -``` - -## Working with a CORE ConformanceReport - -The examples that follow use a captured real report so that the code runs without requiring CORE -to be installed in the docs environment. The structure is identical to what you get from a live -CORE run. +The examples below use a captured real CORE report so that the code runs without requiring CORE to +be installed in the docs environment. The structure is identical to what a live run produces. ```{python} import json from pathlib import Path from pointblank.metadata import parse_core_report, ConformanceReport -# Load a captured CORE 0.16.0 report (SDTMIG 3.4, 430 rules) _fixtures = Path(pb.__file__).parent.parent / "tests" / "metadata_fixtures" / "cdisc_core" raw = json.loads((_fixtures / "core_report_full.json").read_text()) @@ -466,21 +393,16 @@ report = ConformanceReport.from_core_report(raw, agency="FDA") print(report) ``` -### Checking the Overall Result +#### Checking the Overall Result ```{python} -# Single boolean for use in scripts and pipelines print("All passed:", report.all_passed()) - -# Distinguish report types print("Is CORE report:", report.is_core) ``` -### The Summary Dictionary +#### The Summary Dictionary -`summary()` returns a high-level overview of the run: the standard and version CORE validated -against, the engine version that produced the report, total rule count, per-status counts, total -issue count, and the pass/fail verdict: +`summary()` returns run provenance and rule counts: ```{python} s = report.summary() @@ -496,213 +418,132 @@ for status, count in sorted(s["status_counts"].items()): print(f" {status:20s}: {count}") ``` -### Inspecting Issues +#### Issues -`issues()` returns one record per (dataset, rule) pair that reported at least one issue. Each -record includes the dataset name, rule ID, human-readable message, issue count, and the rule's -run status: +`issues()` returns one record per (dataset, rule) pair that reported at least one issue: ```{python} issues = report.issues() print(f"Issue entries: {len(issues)}") -# Show the first few issues for issue in issues[:3]: print() print(f" Dataset: {issue['dataset']}") print(f" Rule: {issue['rule_id']}") print(f" Message: {issue['message']}") print(f" Issues: {issue['issues']}") - print(f" Status: {issue['status']}") ``` -You can filter by run status to focus on a specific category. The two failing statuses are -`"ISSUE REPORTED"` (the rule ran and found a problem) and `"EXECUTION ERROR"` (the rule could -not run): +Filter by status to separate conformance problems from execution errors: ```{python} from pointblank.metadata._cdisc_core import STATUS_ISSUE, STATUS_ERROR -# Rules that actually found conformance problems reported_issues = report.issues(status=STATUS_ISSUE) print(f"Rules with issues: {len(reported_issues)}") -# Rules that failed to execute (data was missing something they expected) exec_errors = report.issues(status=STATUS_ERROR) print(f"Execution errors: {len(exec_errors)}") -for e in exec_errors: - print(f" {e['rule_id']}: {e['message']}") ``` -### Row-Level Findings +#### Row-Level Findings -`findings()` goes deeper: it returns the row-level detail from CORE's `Issue_Details` section. -Each finding points to a specific dataset, row number, USUBJID, and the variable(s) that -triggered the rule: +`findings()` returns the row-level detail from CORE's `Issue_Details` section: ```{python} -from pointblank.metadata import CoreFinding - findings = report.findings() print(f"Row-level findings: {len(findings)}") -# Examine one finding in detail f = findings[0] print() print(f"Rule: {f.rule_id}") print(f"Dataset: {f.dataset}") -print(f"Message: {f.message}") print(f"Row: {f.row}") print(f"USUBJID: {f.usubjid}") print(f"Variables: {f.variables}") print(f"Values: {f.values}") ``` -### Per-Rule Run Results +#### Per-Rule Run Results -`rules()` returns the complete `Rules_Report`: one `CoreRuleResult` per rule, with its run -status, message, and the corresponding CDISC and FDA rule identifiers: +`rules()` returns one `CoreRuleResult` per rule with its run status: ```{python} -from pointblank.metadata import CoreRuleResult from pointblank.metadata._cdisc_core import STATUS_SUCCESS, STATUS_SKIPPED all_rules = report.rules() -print(f"Total rules: {len(all_rules)}") - -# Filter to rules that passed -successful = report.rules(status=STATUS_SUCCESS) -print(f"Successful: {len(successful)}") +print(f"Total rules: {len(all_rules)}") +print(f"Successful: {len(report.rules(status=STATUS_SUCCESS))}") +print(f"Skipped: {len(report.rules(status=STATUS_SKIPPED))}") -# Filter to rules that were skipped (not applicable to this dataset) -skipped = report.rules(status=STATUS_SKIPPED) -print(f"Skipped: {len(skipped)}") - -# Show one successful rule's metadata -r = successful[0] -print() -print(f"Rule ID: {r.rule_id}") -print(f"Status: {r.status}") -print(f"Message: {r.message}") -print(f"CDISC rule ID: {r.cdisc_rule_id}") -print(f"FDA rule ID: {r.fda_rule_id}") -``` - -Rules that are skipped are not failures. CORE skips rules when the dataset or variable they -check is absent from the submission. A rule like "AE requires AESTDTC" is skipped if there is -no AE domain in the package; that is expected behavior, not a problem. - -### Identifying Failing Rules - -The `is_failing` property on a `CoreRuleResult` is `True` when its status is either -`"ISSUE REPORTED"` or `"EXECUTION ERROR"`: - -```{python} failing = [r for r in all_rules if r.is_failing] -print(f"Failing rules: {len(failing)}") +print(f"Failing: {len(failing)}") for r in failing: print(f" {r.rule_id:15s} [{r.status}] {(r.message or '')[:60]}") ``` +Rules marked skipped are not failures. CORE skips rules when the dataset or variable they check is +absent from the submission. + ## Exporting Reports -### JSON Export +### JSON -`to_json()` saves the report as a JSON file. For CORE reports the output mirrors the original -CORE report structure (`Conformance_Details`, `Dataset_Details`, `Issue_Summary`, -`Issue_Details`, `Rules_Report`), so the file is parseable by `parse_core_report()` and by -any other tool that understands CORE's JSON output: +`to_json()` saves the report as a JSON file. For CORE reports the structure mirrors CORE's native +output and is parseable by `parse_core_report()`: ```{python} import tempfile -from pathlib import Path with tempfile.TemporaryDirectory() as tmp: dest = report.to_json(Path(tmp) / "conformance_report.json") print(f"Written to: {dest.name}") print(f"File size: {dest.stat().st_size:,} bytes") - # Verify it round-trips cleanly reloaded = json.loads(dest.read_text()) reparsed = parse_core_report(reloaded) print(f"Rules after round-trip: {len(reparsed.rules)}") - print(f"Issues after round-trip: {reparsed.n_total_issues}") ``` -For native reports the JSON file contains `summary` and `issues` keys, with the same content -as `report.summary()` and `report.issues()`. - -### Excel Export +### Excel -`to_excel()` writes the report as an Excel workbook. Requires `openpyxl`: +`to_excel()` writes the report as a workbook. Requires `openpyxl`: ```{python} #| eval: false dest = report.to_excel("conformance_report.xlsx") ``` -For CORE reports the workbook contains four sheets: +For CORE reports the workbook has four sheets: `Issue_Summary`, `Issue_Details`, `Rules_Report`, +and `Conformance_Details`. -| Sheet | Contents | -|---|---| -| `Issue_Summary` | One row per (dataset, rule) pair that reported issues | -| `Issue_Details` | Row-level findings with USUBJID, row number, and variable values | -| `Rules_Report` | All rules with their run status, CDISC rule ID, and FDA rule ID | -| `Conformance_Details` | Run provenance: standard, version, engine version, timestamps | - -For native reports the workbook contains `Issues` and `Summary` sheets. - -## Setting the Agency +## Using Both Engines Together -Pass `agency="FDA"` or `agency="PMDA"` to record which regulatory context the validation was -run for. The agency is stored on the `ConformanceReport` and appears in its text and HTML -representations. Agency-specific business rule filtering is a later phase; this currently -affects labeling only: +The built-in engine provides fast feedback during development; CORE is the final gate: ```{python} #| eval: false -fda_report = pb.validate_cdisc_submission( - {"DM": dm}, - standard="sdtmig", - version="3.4", - agency="FDA", -) -print(fda_report.agency) # "FDA" -``` - -## Using Both Engines in the Same Workflow +# Step 1: fast iteration with the built-in engine +report = pb.validate_sdtmig({"DM": dm, "AE": ae}, study_id="STUDY01") -The two engines complement each other. The native engine gives immediate feedback during -development with no external dependencies. The CORE engine provides the authoritative check -before submission: - -```{python} -#| eval: false -# Step 1: rapid iteration with the native engine -study = pb.SubmissionPackage( - datasets={"DM": dm, "AE": ae}, - study_id="STUDY01", -) - -native_report = study.validate_conformance() -if not native_report.all_passed(): - print("Fix cross-dataset issues before running CORE:") - for issue in native_report.issues(): - print(f" [{issue['dataset']}] {issue['assertion']}: {issue['n_failed']} rows") +if not report.all_passed(): + print("Fix rule violations before running CORE:") + df = report.findings_df() + print(df.group_by("rule_id").agg(pl.len().alias("n")).sort("n", descending=True)) raise SystemExit(1) -# Step 2: final gate with CORE -core_report = study.validate_conformance( - engine="core", +# Step 2: final gate with CDISC CORE +core_report = pb.validate_cdisc_submission( + {"DM": dm, "AE": ae}, + standard="sdtmig", + version="3.4", agency="FDA", - controlled_terminology="sdtmct-2024-03-29", ) if not core_report.all_passed(): failing = [r for r in core_report.rules() if r.is_failing] print(f"CORE found {len(failing)} failing rules.") core_report.to_json("core_report.json") - core_report.to_excel("core_report.xlsx") raise SystemExit(1) print("Submission passed all conformance checks.") @@ -710,20 +551,15 @@ print("Submission passed all conformance checks.") ## Running the Integration Tests -Pointblank ships integration tests that run the full CORE pipeline against real data. They are -marked with `@pytest.mark.cdisc_core` and are skipped automatically in environments where CORE -is not discoverable: +Pointblank ships integration tests for the full CORE pipeline. They are skipped automatically when +CORE is not discoverable: ```bash -# Run only when CORE is available on PATH: +# Run when CORE is on PATH: pytest -m cdisc_core -# With a repo-checkout CORE, set env vars first: +# With a repo-checkout CORE: export POINTBLANK_CDISC_CORE="python /path/to/core.py" export POINTBLANK_CDISC_CORE_CWD="/path/to/cdisc-rules-engine" -export POINTBLANK_CDISC_CORE_CACHE="/path/to/cdisc-rules-engine/resources/cache" pytest -m cdisc_core ``` - -The tests live in `tests/test_cdisc_core_integration.py` and exercise the full pipeline -including folder passthrough, all report accessors, and both export formats. From a2e4fb56fbf785bd20ce9b60e1b972d7056c3c2f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 18:29:16 -0400 Subject: [PATCH 81/93] Refocus CDISC docs on SDTMIG validation --- great-docs.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/great-docs.yml b/great-docs.yml index f86dcb3a1..ec5b50bce 100644 --- a/great-docs.yml +++ b/great-docs.yml @@ -411,14 +411,14 @@ reference: - title: CDISC Submission Conformance desc: > - Validate an entire study submission package for CDISC conformance, spanning all datasets. - Use `SubmissionPackage` to model a study as a graph of related datasets (with optional - Define-XML and Controlled Terminology context) and `SubmissionPackage.validate_conformance()` - to run native single-dataset structural checks plus cross-dataset checks (USUBJID referential - integrity, SUPP-- linkage, RELREC, and ADaM ⇄ SDTM traceability). With `engine="core"` (or the - `validate_cdisc_submission()` shortcut) the package is handed to the external CDISC CORE engine - for the authoritative rule set. Results are returned as a `ConformanceReport`. + Validate SDTM datasets for CDISC conformance. `validate_sdtmig()` is the primary entry point: + pass a dictionary of domain DataFrames and receive a `ConformanceReport` with 426 SDTMIG 3.4 + rules evaluated in-process. For full submission-package checks (cross-dataset referential + integrity, SUPP-- linkage, Define-XML) use `SubmissionPackage`. For the authoritative + CDISC-certified rule set, use `validate_cdisc_submission()` (requires the CORE CLI) or + `SubmissionPackage.validate_conformance(engine="core")`. All paths return a `ConformanceReport`. contents: + - validate_sdtmig - validate_cdisc_submission - name: SubmissionPackage members: true From 970ba6003f402e05ed48481376395e669176f484 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 18:29:26 -0400 Subject: [PATCH 82/93] Add GT type import in submission module --- pointblank/metadata/_submission.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pointblank/metadata/_submission.py b/pointblank/metadata/_submission.py index 637905cda..85829cc5d 100644 --- a/pointblank/metadata/_submission.py +++ b/pointblank/metadata/_submission.py @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any, Sequence if TYPE_CHECKING: + from great_tables import GT from pointblank.metadata._cdisc_core import ParsedCoreReport from pointblank.metadata._conformance.result import NativeConformanceResult from pointblank.metadata._types import MetadataPackage From f5ac02fdde212e2ce58a720e46d0f650646ba0de Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 19:08:27 -0400 Subject: [PATCH 83/93] Format conformance code and tests --- pointblank/metadata/_conformance/engine.py | 29 +- .../metadata/_conformance/operations.py | 4 +- tests/test_cdisc_core.py | 26 +- tests/test_cdiscpilot01_smoke.py | 10 +- tests/test_native_conformance.py | 329 +++++++++++++----- 5 files changed, 293 insertions(+), 105 deletions(-) diff --git a/pointblank/metadata/_conformance/engine.py b/pointblank/metadata/_conformance/engine.py index 0432de907..d77972c5a 100644 --- a/pointblank/metadata/_conformance/engine.py +++ b/pointblank/metadata/_conformance/engine.py @@ -49,10 +49,25 @@ # Columns checked as candidate identifiers when building a row finding (in priority order). # The first one that's present in the dataset is included in `context`. _CONTEXT_CANDIDATES = [ - "STUDYID", "DOMAIN", "SUBJID", - "VISITNUM", "VISIT", "EPOCH", - "AESEQ", "CMSEQ", "LBSEQ", "VSSEQ", "EXSEQ", "MHSEQ", "DSSEQ", "EGSEQ", - "AETERM", "CMTRT", "LBTESTCD", "VSTESTCD", "EGTESTCD", + "STUDYID", + "DOMAIN", + "SUBJID", + "VISITNUM", + "VISIT", + "EPOCH", + "AESEQ", + "CMSEQ", + "LBSEQ", + "VSSEQ", + "EXSEQ", + "MHSEQ", + "DSSEQ", + "EGSEQ", + "AETERM", + "CMTRT", + "LBTESTCD", + "VSTESTCD", + "EGTESTCD", ] @@ -329,8 +344,7 @@ def _record_check( else: # Exclude SUPP-- and RELREC from catch-all iteration; they have non-standard structure. target_domains = [ - k for k in datasets - if not k.startswith("SUPP") and k not in _STRUCTURAL_DATASETS + k for k in datasets if not k.startswith("SUPP") and k not in _STRUCTURAL_DATASETS ] all_findings: list[NativeRowFinding] = [] n_issues = 0 @@ -394,8 +408,7 @@ def _dataset_metadata_check( else: # Exclude SUPP-- and RELREC from catch-all iteration; they have non-standard structure. target_domains = [ - k for k in datasets - if not k.startswith("SUPP") and k not in _STRUCTURAL_DATASETS + k for k in datasets if not k.startswith("SUPP") and k not in _STRUCTURAL_DATASETS ] n_issues = 0 first_failing_domain = "" diff --git a/pointblank/metadata/_conformance/operations.py b/pointblank/metadata/_conformance/operations.py index f4c721ba8..de332ef53 100644 --- a/pointblank/metadata/_conformance/operations.py +++ b/pointblank/metadata/_conformance/operations.py @@ -92,7 +92,9 @@ def _op_codelist_check( # Build case-insensitive lookup; SAS/XPT missing values arrive as "" — treat as null. upper_terms = {t.upper() for t in terms} values = df[col].to_list() - mask = [True if (v is None or str(v) == "") else (str(v).upper() in upper_terms) for v in values] + mask = [ + True if (v is None or str(v) == "") else (str(v).upper() in upper_terms) for v in values + ] return df.with_columns(_new_bool_series(result_col, mask, df)) diff --git a/tests/test_cdisc_core.py b/tests/test_cdisc_core.py index 363f3cec0..adf8c3bab 100644 --- a/tests/test_cdisc_core.py +++ b/tests/test_cdisc_core.py @@ -624,9 +624,7 @@ def test_validate_cdisc_submission_from_package(tmp_path): import pointblank as pb pkg = _dm_pkg() - rep = pb.validate_cdisc_submission( - pkg, core=_fake_core_cmd(tmp_path), workdir=tmp_path / "w" - ) + rep = pb.validate_cdisc_submission(pkg, core=_fake_core_cmd(tmp_path), workdir=tmp_path / "w") assert rep.is_core assert rep.package is pkg @@ -697,8 +695,15 @@ def test_to_json_native(tmp_path): import pointblank as pb dm = pd.DataFrame( - {"STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["S1-001"], - "SUBJID": ["001"], "ARMCD": ["A"], "ARM": ["A"], "COUNTRY": ["USA"]} + { + "STUDYID": ["S1"], + "DOMAIN": ["DM"], + "USUBJID": ["S1-001"], + "SUBJID": ["001"], + "ARMCD": ["A"], + "ARM": ["A"], + "COUNTRY": ["USA"], + } ) rep = pb.SubmissionPackage(datasets={"DM": dm}).validate_conformance() dest = rep.to_json(tmp_path / "native_report.json") @@ -747,8 +752,15 @@ def test_to_excel_native(tmp_path): import pointblank as pb dm = pd.DataFrame( - {"STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["S1-001"], - "SUBJID": ["001"], "ARMCD": ["A"], "ARM": ["A"], "COUNTRY": ["USA"]} + { + "STUDYID": ["S1"], + "DOMAIN": ["DM"], + "USUBJID": ["S1-001"], + "SUBJID": ["001"], + "ARMCD": ["A"], + "ARM": ["A"], + "COUNTRY": ["USA"], + } ) rep = pb.SubmissionPackage(datasets={"DM": dm}).validate_conformance() dest = rep.to_excel(tmp_path / "native_report.xlsx") diff --git a/tests/test_cdiscpilot01_smoke.py b/tests/test_cdiscpilot01_smoke.py index 0a25f1a6d..7446110d7 100644 --- a/tests/test_cdiscpilot01_smoke.py +++ b/tests/test_cdiscpilot01_smoke.py @@ -350,7 +350,15 @@ def test_findings_df_schema(pilot_report): df = pilot_report.findings_df() assert isinstance(df, pl.DataFrame) - expected = {"rule_id", "dataset", "row_index", "usubjid", "checked_column", "checked_value", "description"} + expected = { + "rule_id", + "dataset", + "row_index", + "usubjid", + "checked_column", + "checked_value", + "description", + } assert expected.issubset(set(df.columns)) diff --git a/tests/test_native_conformance.py b/tests/test_native_conformance.py index 8bf86e0d5..1a0998732 100644 --- a/tests/test_native_conformance.py +++ b/tests/test_native_conformance.py @@ -65,21 +65,32 @@ def engine(): def _clean_ta() -> pl.DataFrame: - return pl.DataFrame({ - "STUDYID": ["S001", "S001"], "DOMAIN": ["TA", "TA"], - "ARMCD": ["A", "B"], "ARM": ["Arm A", "Arm B"], - "TAETORD": [1, 1], "EPOCH": ["TREATMENT", "TREATMENT"], - "ELEMENT": ["Element 1", "Element 1"], "ETCD": ["ET1", "ET1"], - "TASEQ": [1, 2], - }) + return pl.DataFrame( + { + "STUDYID": ["S001", "S001"], + "DOMAIN": ["TA", "TA"], + "ARMCD": ["A", "B"], + "ARM": ["Arm A", "Arm B"], + "TAETORD": [1, 1], + "EPOCH": ["TREATMENT", "TREATMENT"], + "ELEMENT": ["Element 1", "Element 1"], + "ETCD": ["ET1", "ET1"], + "TASEQ": [1, 2], + } + ) def _clean_ts() -> pl.DataFrame: - return pl.DataFrame({ - "STUDYID": ["S001"], "DOMAIN": ["TS"], - "TSSEQ": [1], "TSPARMCD": ["PLANSUB"], - "TSPARM": ["Planned Number of Subjects"], "TSVAL": ["100"], - }) + return pl.DataFrame( + { + "STUDYID": ["S001"], + "DOMAIN": ["TS"], + "TSSEQ": [1], + "TSPARMCD": ["PLANSUB"], + "TSPARM": ["Planned Number of Subjects"], + "TSVAL": ["100"], + } + ) @pytest.fixture @@ -506,10 +517,18 @@ def test_engine_row_finding_has_usubjid(engine): def test_engine_row_finding_has_date_column(engine): dm = pl.DataFrame( { - "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], "SUBJID": ["1"], - "SEX": ["M"], "RACE": ["WHITE"], "ETHNIC": ["NOT HISPANIC OR LATINO"], - "COUNTRY": ["USA"], "ARMCD": ["A"], "ARM": ["Arm A"], - "ACTARMCD": ["A"], "ACTARM": ["Arm A"], + "STUDYID": ["S1"], + "DOMAIN": ["DM"], + "USUBJID": ["U1"], + "SUBJID": ["1"], + "SEX": ["M"], + "RACE": ["WHITE"], + "ETHNIC": ["NOT HISPANIC OR LATINO"], + "COUNTRY": ["USA"], + "ARMCD": ["A"], + "ARM": ["Arm A"], + "ACTARMCD": ["A"], + "ACTARM": ["Arm A"], "DMDTC": ["not-a-date"], } ) @@ -643,13 +662,19 @@ def test_submission_package_findings_accessor(): def test_findings_df_returns_dataframe(): import polars as pl - dirty = pl.DataFrame({ - "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], "SEX": ["BAD"] - }) + dirty = pl.DataFrame({"STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], "SEX": ["BAD"]}) report = pb.validate_sdtmig({"DM": dirty}) df = report.findings_df() assert isinstance(df, pl.DataFrame) - expected_cols = {"rule_id", "dataset", "row_index", "usubjid", "checked_column", "checked_value", "description"} + expected_cols = { + "rule_id", + "dataset", + "row_index", + "usubjid", + "checked_column", + "checked_value", + "description", + } assert expected_cols.issubset(set(df.columns)) assert len(df) > 0 @@ -657,10 +682,15 @@ def test_findings_df_returns_dataframe(): def test_findings_df_captures_correct_fields(): import polars as pl - dirty = pl.DataFrame({ - "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U99"], - "SUBJID": ["99"], "SEX": ["Q"], - }) + dirty = pl.DataFrame( + { + "STUDYID": ["S1"], + "DOMAIN": ["DM"], + "USUBJID": ["U99"], + "SUBJID": ["99"], + "SEX": ["Q"], + } + ) report = pb.validate_sdtmig({"DM": dirty}) df = report.findings_df() sex_row = df.filter(pl.col("rule_id") == "SDTM-007") @@ -695,10 +725,15 @@ def test_get_findings_table_returns_gt(): import polars as pl from great_tables import GT - dirty = pl.DataFrame({ - "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["U1"], - "SUBJID": ["1"], "SEX": ["Q"], - }) + dirty = pl.DataFrame( + { + "STUDYID": ["S1"], + "DOMAIN": ["DM"], + "USUBJID": ["U1"], + "SUBJID": ["1"], + "SEX": ["Q"], + } + ) report = pb.validate_sdtmig({"DM": dirty}) gt = report.get_findings_table() assert isinstance(gt, GT) @@ -774,7 +809,7 @@ def test_jsonata_grouped(): def test_jsonata_string_functions(): assert evaluate_jsonata('$uppercase("ae")', {}) == "AE" assert evaluate_jsonata('$lowercase("AE")', {}) == "ae" - assert evaluate_jsonata('$string(42)', {}) == "42" + assert evaluate_jsonata("$string(42)", {}) == "42" assert evaluate_jsonata('$length("hello")', {}) == 5 assert evaluate_jsonata('$trim(" hi ")', {}) == "hi" @@ -801,6 +836,7 @@ def test_jsonata_context_field_expression(): def test_jsonata_not_supported_filter(): import pytest + # Filter expressions VALS[...] are not supported; raises either # JSONataNotSupported (when reached during evaluation) or JSONataSyntaxError # (when the parser hits unexpected '[' after consuming VALS). @@ -810,6 +846,7 @@ def test_jsonata_not_supported_filter(): def test_jsonata_syntax_error(): import pytest + with pytest.raises(JSONataSyntaxError): evaluate_jsonata("= broken", {}) @@ -823,9 +860,16 @@ def test_has_required_variables_all_present(): import polars as pl import narwhals as nw - df = nw.from_native(pl.DataFrame({"STUDYID": ["X"], "DOMAIN": ["DM"], "USUBJID": ["U1"]}), eager_only=True) + df = nw.from_native( + pl.DataFrame({"STUDYID": ["X"], "DOMAIN": ["DM"], "USUBJID": ["U1"]}), eager_only=True + ) ct = ControlledTerminology({}, []) - ops = [{"operator": "has_required_variables", "params": {"variables": ["STUDYID", "DOMAIN", "USUBJID"]}}] + ops = [ + { + "operator": "has_required_variables", + "params": {"variables": ["STUDYID", "DOMAIN", "USUBJID"]}, + } + ] result = apply_operations(df, ops, ct, {}) assert result["_pb_STUDYID_present"].to_list() == [True] assert result["_pb_DOMAIN_present"].to_list() == [True] @@ -852,9 +896,16 @@ def test_valid_variable_order_correct(): import polars as pl import narwhals as nw - df = nw.from_native(pl.DataFrame({"STUDYID": ["X"], "DOMAIN": ["DM"], "USUBJID": ["U1"]}), eager_only=True) + df = nw.from_native( + pl.DataFrame({"STUDYID": ["X"], "DOMAIN": ["DM"], "USUBJID": ["U1"]}), eager_only=True + ) ct = ControlledTerminology({}, []) - ops = [{"operator": "valid_variable_order", "params": {"expected_order": ["STUDYID", "DOMAIN", "USUBJID"]}}] + ops = [ + { + "operator": "valid_variable_order", + "params": {"expected_order": ["STUDYID", "DOMAIN", "USUBJID"]}, + } + ] result = apply_operations(df, ops, ct, {}) assert result["_pb_variable_order_valid"].to_list() == [True] @@ -866,9 +917,16 @@ def test_valid_variable_order_wrong(): import narwhals as nw # DOMAIN appears before STUDYID - df = nw.from_native(pl.DataFrame({"DOMAIN": ["DM"], "STUDYID": ["X"], "USUBJID": ["U1"]}), eager_only=True) + df = nw.from_native( + pl.DataFrame({"DOMAIN": ["DM"], "STUDYID": ["X"], "USUBJID": ["U1"]}), eager_only=True + ) ct = ControlledTerminology({}, []) - ops = [{"operator": "valid_variable_order", "params": {"expected_order": ["STUDYID", "DOMAIN", "USUBJID"]}}] + ops = [ + { + "operator": "valid_variable_order", + "params": {"expected_order": ["STUDYID", "DOMAIN", "USUBJID"]}, + } + ] result = apply_operations(df, ops, ct, {}) assert result["_pb_variable_order_valid"].to_list() == [False] @@ -882,7 +940,12 @@ def test_valid_variable_order_absent_columns_skipped(): # DOMAIN absent; remaining two are in order → True df = nw.from_native(pl.DataFrame({"STUDYID": ["X"], "USUBJID": ["U1"]}), eager_only=True) ct = ControlledTerminology({}, []) - ops = [{"operator": "valid_variable_order", "params": {"expected_order": ["STUDYID", "DOMAIN", "USUBJID"]}}] + ops = [ + { + "operator": "valid_variable_order", + "params": {"expected_order": ["STUDYID", "DOMAIN", "USUBJID"]}, + } + ] result = apply_operations(df, ops, ct, {}) assert result["_pb_variable_order_valid"].to_list() == [True] @@ -895,7 +958,9 @@ def test_variable_type_check_numeric_ok(): df = nw.from_native(pl.DataFrame({"AGE": [45.0]}), eager_only=True) ct = ControlledTerminology({}, []) - ops = [{"operator": "variable_type_check", "params": {"column": "AGE", "expected_type": "numeric"}}] + ops = [ + {"operator": "variable_type_check", "params": {"column": "AGE", "expected_type": "numeric"}} + ] result = apply_operations(df, ops, ct, {}) assert result["_pb_AGE_type_valid"].to_list() == [True] @@ -908,7 +973,9 @@ def test_variable_type_check_numeric_fail(): df = nw.from_native(pl.DataFrame({"AGE": ["45"]}), eager_only=True) # string, not numeric ct = ControlledTerminology({}, []) - ops = [{"operator": "variable_type_check", "params": {"column": "AGE", "expected_type": "numeric"}}] + ops = [ + {"operator": "variable_type_check", "params": {"column": "AGE", "expected_type": "numeric"}} + ] result = apply_operations(df, ops, ct, {}) assert result["_pb_AGE_type_valid"].to_list() == [False] @@ -921,7 +988,9 @@ def test_variable_type_check_absent_column_passes(): df = nw.from_native(pl.DataFrame({"STUDYID": ["X"]}), eager_only=True) ct = ControlledTerminology({}, []) - ops = [{"operator": "variable_type_check", "params": {"column": "AGE", "expected_type": "numeric"}}] + ops = [ + {"operator": "variable_type_check", "params": {"column": "AGE", "expected_type": "numeric"}} + ] result = apply_operations(df, ops, ct, {}) assert result["_pb_AGE_type_valid"].to_list() == [True] @@ -930,14 +999,27 @@ def test_variable_type_check_absent_column_passes(): def _full_dm() -> pl.DataFrame: - return pl.DataFrame({ - "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["S1-001"], "SUBJID": ["001"], - "RFSTDTC": ["2020-01-01"], "RFENDTC": ["2020-06-30"], - "SITEID": ["001"], "AGE": [45.0], "AGEU": ["YEARS"], - "SEX": ["M"], "RACE": ["WHITE"], "ETHNIC": ["NOT HISPANIC OR LATINO"], - "COUNTRY": ["USA"], "ARMCD": ["A"], "ARM": ["Arm A"], - "ACTARMCD": ["A"], "ACTARM": ["Arm A"], - }) + return pl.DataFrame( + { + "STUDYID": ["S1"], + "DOMAIN": ["DM"], + "USUBJID": ["S1-001"], + "SUBJID": ["001"], + "RFSTDTC": ["2020-01-01"], + "RFENDTC": ["2020-06-30"], + "SITEID": ["001"], + "AGE": [45.0], + "AGEU": ["YEARS"], + "SEX": ["M"], + "RACE": ["WHITE"], + "ETHNIC": ["NOT HISPANIC OR LATINO"], + "COUNTRY": ["USA"], + "ARMCD": ["A"], + "ARM": ["Arm A"], + "ACTARMCD": ["A"], + "ACTARM": ["Arm A"], + } + ) def test_variable_metadata_check_passes_for_complete_dm(): @@ -946,8 +1028,12 @@ def test_variable_metadata_check_passes_for_complete_dm(): vmc = [r for r in result.rule_results if r.rule_type == "VARIABLE_METADATA_CHECK"] assert len(vmc) > 0 # With a complete DM, all Fully Executable VMC rules on DM should pass. - dm_rules = [r for r in vmc if "DM" in r.dataset and r.status not in ("not_supported", "not_applicable")] - assert all(r.status == "pass" for r in dm_rules), [(r.rule_id, r.status, r.message) for r in dm_rules] + dm_rules = [ + r for r in vmc if "DM" in r.dataset and r.status not in ("not_supported", "not_applicable") + ] + assert all(r.status == "pass" for r in dm_rules), [ + (r.rule_id, r.status, r.message) for r in dm_rules + ] def test_variable_metadata_check_fails_missing_sex(): @@ -960,7 +1046,9 @@ def test_variable_metadata_check_fails_missing_sex(): def test_variable_metadata_check_fails_wrong_order(): - dm = _full_dm().select(["DOMAIN", "STUDYID"] + [c for c in _full_dm().columns if c not in ("DOMAIN", "STUDYID")]) + dm = _full_dm().select( + ["DOMAIN", "STUDYID"] + [c for c in _full_dm().columns if c not in ("DOMAIN", "STUDYID")] + ) engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["VARIABLE_METADATA_CHECK"]) result = engine.run({"DM": dm}) vmc = {r.rule_id: r for r in result.rule_results if r.rule_type == "VARIABLE_METADATA_CHECK"} @@ -981,7 +1069,9 @@ def test_partially_executable_runs_when_dataset_provided(): # When the required dataset IS present, the rule should not return not_applicable. engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["VARIABLE_METADATA_CHECK"]) # SDTM-049/050 have empty conditions so they'll pass on any dataset. - stub_define = MetadataPackage(items={"DM": MetadataImport(source_format="cdisc_define", dataset_name="DM")}) + stub_define = MetadataPackage( + items={"DM": MetadataImport(source_format="cdisc_define", dataset_name="DM")} + ) result = engine.run({"DM": _full_dm()}, define_xml=stub_define) partial = {r.rule_id: r for r in result.rule_results if r.rule_id in ("SDTM-049", "SDTM-050")} assert all(r.status != "not_applicable" for r in partial.values()) @@ -990,12 +1080,26 @@ def test_partially_executable_runs_when_dataset_provided(): # ── Phase 3: Define-XML operations and handlers ─────────────────────────────── -def _make_var(name: str, dtype: str = "String", required: bool = False, allowed_values=None, display_format: str | None = None) -> VariableMetadata: - return VariableMetadata(name=name, dtype=dtype, required=required, allowed_values=allowed_values, display_format=display_format) +def _make_var( + name: str, + dtype: str = "String", + required: bool = False, + allowed_values=None, + display_format: str | None = None, +) -> VariableMetadata: + return VariableMetadata( + name=name, + dtype=dtype, + required=required, + allowed_values=allowed_values, + display_format=display_format, + ) def _make_define_pkg(domain: str, variables: list[VariableMetadata]) -> MetadataPackage: - meta = MetadataImport(source_format="cdisc_define", dataset_name=domain, domain=domain, variables=variables) + meta = MetadataImport( + source_format="cdisc_define", dataset_name=domain, domain=domain, variables=variables + ) return MetadataPackage(items={domain.upper(): meta}) @@ -1005,7 +1109,9 @@ def test_define_var_declared_present(): import narwhals as nw define_meta = MetadataImport( - source_format="cdisc_define", dataset_name="DM", domain="DM", + source_format="cdisc_define", + dataset_name="DM", + domain="DM", variables=[_make_var("STUDYID"), _make_var("SEX")], ) df = nw.from_native(pl.DataFrame({"STUDYID": ["X"], "SEX": ["M"]}), eager_only=True) @@ -1036,7 +1142,9 @@ def test_define_required_check_passes_non_null(): import narwhals as nw define_meta = MetadataImport( - source_format="cdisc_define", dataset_name="DM", domain="DM", + source_format="cdisc_define", + dataset_name="DM", + domain="DM", variables=[_make_var("STUDYID", required=True)], ) df = nw.from_native(pl.DataFrame({"STUDYID": ["S1", "S2"]}), eager_only=True) @@ -1051,7 +1159,9 @@ def test_define_required_check_flags_null(): import narwhals as nw define_meta = MetadataImport( - source_format="cdisc_define", dataset_name="DM", domain="DM", + source_format="cdisc_define", + dataset_name="DM", + domain="DM", variables=[_make_var("STUDYID", required=True)], ) df = nw.from_native(pl.DataFrame({"STUDYID": ["S1", None]}), eager_only=True) @@ -1066,7 +1176,9 @@ def test_define_required_check_not_mandatory_always_true(): import narwhals as nw define_meta = MetadataImport( - source_format="cdisc_define", dataset_name="DM", domain="DM", + source_format="cdisc_define", + dataset_name="DM", + domain="DM", variables=[_make_var("OPTIONAL_VAR", required=False)], ) df = nw.from_native(pl.DataFrame({"OPTIONAL_VAR": ["X", None]}), eager_only=True) @@ -1081,7 +1193,9 @@ def test_define_codelist_check_valid_values(): import narwhals as nw define_meta = MetadataImport( - source_format="cdisc_define", dataset_name="DM", domain="DM", + source_format="cdisc_define", + dataset_name="DM", + domain="DM", variables=[_make_var("SEX", allowed_values=["M", "F", "U"])], ) df = nw.from_native(pl.DataFrame({"SEX": ["M", "F", "INVALID"]}), eager_only=True) @@ -1096,7 +1210,9 @@ def test_define_codelist_check_null_passes(): import narwhals as nw define_meta = MetadataImport( - source_format="cdisc_define", dataset_name="DM", domain="DM", + source_format="cdisc_define", + dataset_name="DM", + domain="DM", variables=[_make_var("SEX", allowed_values=["M", "F"])], ) df = nw.from_native(pl.DataFrame({"SEX": ["M", None]}), eager_only=True) @@ -1111,7 +1227,9 @@ def test_define_codelist_check_no_codelist_always_true(): import narwhals as nw define_meta = MetadataImport( - source_format="cdisc_define", dataset_name="DM", domain="DM", + source_format="cdisc_define", + dataset_name="DM", + domain="DM", variables=[_make_var("NOTES")], # no allowed_values ) df = nw.from_native(pl.DataFrame({"NOTES": ["anything"]}), eager_only=True) @@ -1126,7 +1244,9 @@ def test_define_type_check_numeric_ok(): import narwhals as nw define_meta = MetadataImport( - source_format="cdisc_define", dataset_name="DM", domain="DM", + source_format="cdisc_define", + dataset_name="DM", + domain="DM", variables=[_make_var("AGE", dtype="Float64", display_format="float")], ) df = nw.from_native(pl.DataFrame({"AGE": [45.0]}), eager_only=True) @@ -1141,7 +1261,9 @@ def test_define_type_check_char_mismatch(): import narwhals as nw define_meta = MetadataImport( - source_format="cdisc_define", dataset_name="DM", domain="DM", + source_format="cdisc_define", + dataset_name="DM", + domain="DM", variables=[_make_var("STUDYID", display_format="text")], ) df = nw.from_native(pl.DataFrame({"STUDYID": [1, 2]}), eager_only=True) # numeric, not text @@ -1154,25 +1276,42 @@ def test_define_type_check_char_mismatch(): def _dm_with_bad_sex() -> pl.DataFrame: - return pl.DataFrame({ - "STUDYID": ["S1"], "DOMAIN": ["DM"], "USUBJID": ["S1-001"], "SUBJID": ["001"], - "SEX": ["INVALID"], "RACE": ["WHITE"], "ETHNIC": ["NOT HISPANIC OR LATINO"], - "COUNTRY": ["USA"], "AGE": [45.0], "AGEU": ["YEARS"], "SITEID": ["001"], - "RFSTDTC": ["2020-01-01"], "RFENDTC": ["2020-06-30"], - "ARMCD": ["A"], "ARM": ["Arm A"], "ACTARMCD": ["A"], "ACTARM": ["Arm A"], - }) + return pl.DataFrame( + { + "STUDYID": ["S1"], + "DOMAIN": ["DM"], + "USUBJID": ["S1-001"], + "SUBJID": ["001"], + "SEX": ["INVALID"], + "RACE": ["WHITE"], + "ETHNIC": ["NOT HISPANIC OR LATINO"], + "COUNTRY": ["USA"], + "AGE": [45.0], + "AGEU": ["YEARS"], + "SITEID": ["001"], + "RFSTDTC": ["2020-01-01"], + "RFENDTC": ["2020-06-30"], + "ARMCD": ["A"], + "ARM": ["Arm A"], + "ACTARMCD": ["A"], + "ACTARM": ["Arm A"], + } + ) def _dm_define_pkg() -> MetadataPackage: - return _make_define_pkg("DM", [ - _make_var("STUDYID", required=True), - _make_var("DOMAIN", required=True), - _make_var("USUBJID", required=True), - _make_var("SEX", allowed_values=["M", "F", "U", "UNDIFFERENTIATED"]), - _make_var("RACE", allowed_values=["WHITE", "BLACK OR AFRICAN AMERICAN", "ASIAN"]), - _make_var("ETHNIC", allowed_values=["NOT HISPANIC OR LATINO", "HISPANIC OR LATINO"]), - _make_var("AGE", dtype="Float64", display_format="float"), - ]) + return _make_define_pkg( + "DM", + [ + _make_var("STUDYID", required=True), + _make_var("DOMAIN", required=True), + _make_var("USUBJID", required=True), + _make_var("SEX", allowed_values=["M", "F", "U", "UNDIFFERENTIATED"]), + _make_var("RACE", allowed_values=["WHITE", "BLACK OR AFRICAN AMERICAN", "ASIAN"]), + _make_var("ETHNIC", allowed_values=["NOT HISPANIC OR LATINO", "HISPANIC OR LATINO"]), + _make_var("AGE", dtype="Float64", display_format="float"), + ], + ) def test_define_item_metadata_check_all_declared(): @@ -1186,10 +1325,15 @@ def test_define_item_metadata_check_all_declared(): def test_define_item_metadata_check_undeclared_variable(): engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["DEFINE_ITEM_METADATA_CHECK"]) # Provide a Define-XML that does NOT declare DOMAIN - pkg = _make_define_pkg("DM", [ - _make_var("STUDYID", required=True), - _make_var("USUBJID"), _make_var("SEX"), _make_var("AGE", display_format="float"), - ]) + pkg = _make_define_pkg( + "DM", + [ + _make_var("STUDYID", required=True), + _make_var("USUBJID"), + _make_var("SEX"), + _make_var("AGE", display_format="float"), + ], + ) result = engine.run({"DM": _clean_dm()}, define_xml=pkg) sdtm_051 = next(r for r in result.rule_results if r.rule_id == "SDTM-051") assert sdtm_051.status == "fail" @@ -1214,7 +1358,8 @@ def test_define_codelist_check_passes_valid_values(): def test_define_rules_not_applicable_without_define_xml(): engine = NativeConformanceEngine( - "sdtmig", "3.4", + "sdtmig", + "3.4", rule_types=["DEFINE_ITEM_METADATA_CHECK", "DEFINE_CODELIST_CHECK"], ) result = engine.run({"DM": _clean_dm()}) # no define_xml @@ -1224,7 +1369,8 @@ def test_define_rules_not_applicable_without_define_xml(): def test_define_rules_applicable_with_define_xml(): engine = NativeConformanceEngine( - "sdtmig", "3.4", + "sdtmig", + "3.4", rule_types=["DEFINE_ITEM_METADATA_CHECK", "DEFINE_CODELIST_CHECK"], ) result = engine.run({"DM": _clean_dm()}, define_xml=_dm_define_pkg()) @@ -1235,9 +1381,16 @@ def test_define_rules_applicable_with_define_xml(): def test_engine_accepts_metadata_import_directly(): engine = NativeConformanceEngine("sdtmig", "3.4", rule_types=["DEFINE_ITEM_METADATA_CHECK"]) dm_meta = MetadataImport( - source_format="cdisc_define", dataset_name="DM", domain="DM", - variables=[_make_var("STUDYID", required=True), _make_var("DOMAIN"), _make_var("USUBJID"), - _make_var("SEX"), _make_var("AGE", display_format="float")], + source_format="cdisc_define", + dataset_name="DM", + domain="DM", + variables=[ + _make_var("STUDYID", required=True), + _make_var("DOMAIN"), + _make_var("USUBJID"), + _make_var("SEX"), + _make_var("AGE", display_format="float"), + ], ) result = engine.run({"DM": _clean_dm()}, define_xml=dm_meta) sdtm_051 = next(r for r in result.rule_results if r.rule_id == "SDTM-051") From 43131599e18bd1fd3552ab01eeb1f4c0b9acec19 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 20:03:43 -0400 Subject: [PATCH 84/93] Update 04-cdisc-submission-conformance.qmd --- .../11-metadata-import/04-cdisc-submission-conformance.qmd | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd index 6abb2c2a7..539cdd421 100644 --- a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -27,6 +27,12 @@ CDISC-certified rule set. Requires the CORE executable to be installed separatel ## SDTMIG Conformance +The built-in engine covers the complete SDTMIG 3.4 rule catalog, spanning per-record value checks, +variable metadata, dataset-level constraints, domain presence, and Define-XML cross-references. The +sections below walk through the three output surfaces (the tabular conformance report, the +record-level findings table, and the programmatic findings DataFrame) and describe the rule catalog, +controlled terminology handling, and optional inputs such as custom CT packages and Define-XML. + ### Quick Start [`validate_sdtmig()`](`pointblank.validate_sdtmig`) is the entry point for SDTMIG conformance. From a628eccb537c026b8c41205c60bcd37cce103a38 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 20:03:54 -0400 Subject: [PATCH 85/93] Update 04-cdisc-submission-conformance.qmd --- .../11-metadata-import/04-cdisc-submission-conformance.qmd | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd index 539cdd421..15f43c0f1 100644 --- a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -68,7 +68,9 @@ report ``` Domain keys are matched case-insensitively (`"dm"` and `"DM"` are equivalent). Polars, pandas, and -any other narwhals-compatible DataFrame are accepted. +any other narwhals-compatible DataFrame are accepted. The report object itself renders the tabular +summary in a notebook; read on to learn how to drill into failures and access findings +programmatically. ### The Conformance Report From caa9466282c244ea0e9faedf1e591fd7816954ef Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 20:04:27 -0400 Subject: [PATCH 86/93] Update 04-cdisc-submission-conformance.qmd --- .../11-metadata-import/04-cdisc-submission-conformance.qmd | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd index 15f43c0f1..dc6e6589f 100644 --- a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -100,6 +100,11 @@ gt = report.get_tabular_report() gt ``` +The `GT` object can be passed to any Great Tables export method (for example, `gt.as_raw_html()` to +embed the table in a custom HTML report, or `gt.gtsave("report.png")` to render it as an image). +When failures are present, use the findings surfaces below to investigate the specific records that +triggered each rule. + ### Findings Drill-Down When rules fail, the tabular report shows how many records violated each rule but not which ones. From 0c96fbc3bf3e9927b417c70dac0f1c42c3fdcccf Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 20:04:36 -0400 Subject: [PATCH 87/93] Update 04-cdisc-submission-conformance.qmd --- .../11-metadata-import/04-cdisc-submission-conformance.qmd | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd index dc6e6589f..4941d964b 100644 --- a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -145,7 +145,8 @@ The findings table groups its columns into two spanners: `Row` is the 1-based row number in the source dataset for quick lookup. At most 100 findings per rule are shown in the table. The true total for each rule is always visible -in `get_tabular_report()`. +in `get_tabular_report()`. To export findings to a spreadsheet or join them back to your source +data, use `findings_df()` instead. ### Programmatic Access with findings_df() From c0998623b19d0c0a1c1de9c3869115a2d28f83ec Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 20:04:39 -0400 Subject: [PATCH 88/93] Update 04-cdisc-submission-conformance.qmd --- .../11-metadata-import/04-cdisc-submission-conformance.qmd | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd index 4941d964b..a216225bd 100644 --- a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -184,7 +184,8 @@ df.group_by("rule_id").agg(pl.len().alias("n_violations")).sort("n_violations", ``` The DataFrame is empty (with the same schema) when all rules pass, so it is safe to call -unconditionally in a script. +unconditionally in a script. Use it to triage failures before running the more time-intensive CDISC +CORE engine, or to generate a custom summary report tailored to your team's workflow. ### What the Rule Catalog Covers @@ -206,7 +207,9 @@ Only `RECORD_CHECK` and `DATASET_CONTENTS_CHECK` rules produce row-level finding Rules that require a domain or variable not present in your datasets are automatically marked `not_applicable` (they are not counted as failures). For example, a rule that checks `AESTDTC` in -the AE domain is `not_applicable` when no AE dataset is supplied. +the AE domain is `not_applicable` when no AE dataset is supplied. Adding more domains to the +dictionary passed to `validate_sdtmig()` will convert more rules from `not_applicable` to +executable, giving a more complete conformance picture. ### Controlled Terminology From 29b1f333b83a403ee96ab481e36cd6a49e731f03 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 20:04:46 -0400 Subject: [PATCH 89/93] Update 04-cdisc-submission-conformance.qmd --- .../11-metadata-import/04-cdisc-submission-conformance.qmd | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd index a216225bd..b2be8cd78 100644 --- a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -222,6 +222,9 @@ This avoids false positives for studies that applied mixed-case CT values. checks for such cells, preventing the large volumes of false positives that occur with a naive string comparison. +Both behaviors apply automatically with no configuration. If you need to pin a different CT version +or supply additional packages, see the next section. + ### Supply a Custom CT Package By default the most recent bundled CT package is used. Pass `ct_packages` to pin a specific version @@ -235,6 +238,9 @@ report = pb.validate_sdtmig( ) ``` +Pinning the CT version is useful when your study was locked against a specific CDISC CT release and +you want the conformance check to reflect that snapshot rather than the latest terms. + ### Activating Define-XML Rules Pass a path to `define.xml` to activate the Define-XML-aware rule types: From e12c62e345704e4cc1e27f9174aa41cbd37dc527 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 20:04:53 -0400 Subject: [PATCH 90/93] Update 04-cdisc-submission-conformance.qmd --- .../11-metadata-import/04-cdisc-submission-conformance.qmd | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd index b2be8cd78..3634b54c7 100644 --- a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -254,7 +254,9 @@ report = pb.validate_sdtmig( ``` Without `define_xml`, the 76 `DEFINE_ITEM_METADATA_CHECK` and `DEFINE_CODELIST_CHECK` rules are -marked `not_applicable`. +marked `not_applicable`. Supplying the file allows the engine to verify that every variable present +in your datasets is declared in Define-XML with the correct type and, where applicable, a valid +codelist. ### Loading XPT Files @@ -283,7 +285,8 @@ report = pb.validate_sdtmig( ``` Pointblank handles the SAS empty-string convention automatically, so XPT data does not require any -preprocessing before being passed to `validate_sdtmig()`. +preprocessing before being passed to `validate_sdtmig()`. The same built-in handling applies whether +data arrives from XPT files, in-memory DataFrames, or any other narwhals-compatible source. ## Prerequisites From 3bd2e038b8e0cdd8e99e8fa65e9b1a0ac3349f7b Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 20:04:59 -0400 Subject: [PATCH 91/93] Update 04-cdisc-submission-conformance.qmd --- .../04-cdisc-submission-conformance.qmd | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd index 3634b54c7..856e2c839 100644 --- a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -313,9 +313,11 @@ gives a clean result. ### Installing the CDISC CORE Engine -#### Option 1: Standalone Executable +CORE can be obtained in three ways depending on your environment. The standalone executable is +easiest for local use, Docker is preferred in containerized CI pipelines, and the repo checkout is +useful when you need a specific unreleased version or want to inspect the rule definitions directly. -Download the pre-built standalone executable from the +**Option 1: Standalone executable.** Download the pre-built binary from the [CDISC CORE releases page](https://github.com/cdisc-org/cdisc-rules-engine/releases). Place it somewhere on your `PATH` under the name `core`: @@ -325,9 +327,11 @@ sudo mv core /usr/local/bin/ core --version ``` -#### Option 2: Docker +Once the binary is on `PATH`, Pointblank will discover it automatically with no additional +configuration required. -CDISC publishes an official Docker image that includes the engine and its full rules cache: +**Option 2: Docker.** CDISC publishes an official Docker image that includes the engine and its +full rules cache: ```bash docker pull cdisc/cdisc-rules-engine:latest From 3ed3aa8195bc33d05589cc9ad12e3f5363cf8a19 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 20:05:07 -0400 Subject: [PATCH 92/93] Update 04-cdisc-submission-conformance.qmd --- .../04-cdisc-submission-conformance.qmd | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd index 856e2c839..a50e16daf 100644 --- a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -342,7 +342,11 @@ docker run --rm \ validate -s sdtmig -v 3-4 -d /data -of JSON -o /data/report ``` -#### Option 3: Repo Checkout +When running via Docker you will typically invoke CORE directly rather than through Pointblank's +subprocess wrapper. The resulting JSON output can be loaded with `parse_core_report()` and wrapped +in a `ConformanceReport` for further analysis. + +**Option 3: Repo checkout.** ```bash git clone https://github.com/cdisc-org/cdisc-rules-engine.git @@ -364,6 +368,10 @@ Pointblank discovers CORE through three mechanisms, tried in order: `export POINTBLANK_CDISC_CORE="python /path/to/core.py"`). 3. A `core` or `cdisc-rules-engine` executable on `PATH`. +The environment variable approach is convenient for CI systems where the CORE path differs between +machines, while the explicit argument is useful in notebooks where you want the path to be +self-documenting. + ### Running CORE `validate_cdisc_submission()` is the simplest way to run a CORE validation. It accepts an in-memory @@ -406,6 +414,9 @@ report = pb.validate_cdisc_submission( ) ``` +In all cases the return value is a `ConformanceReport` with `is_core=True`. The sections below +describe the accessor methods available on a CORE report. + ### Working with a CORE ConformanceReport The examples below use a captured real CORE report so that the code runs without requiring CORE to @@ -423,16 +434,16 @@ report = ConformanceReport.from_core_report(raw, agency="FDA") print(report) ``` -#### Checking the Overall Result +**Overall result.** `all_passed()` returns `True` when no rules reported issues; `is_core` +confirms the report originated from the CORE engine rather than the built-in engine: ```{python} print("All passed:", report.all_passed()) print("Is CORE report:", report.is_core) ``` -#### The Summary Dictionary - -`summary()` returns run provenance and rule counts: +**Summary dictionary.** `summary()` returns run provenance and rule counts as a plain dictionary, +useful for logging, assertions in CI scripts, or building a custom status page: ```{python} s = report.summary() From b2757ba7de3d0b79e1945d8a72262dc58d435cf7 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 14 Jul 2026 20:05:33 -0400 Subject: [PATCH 93/93] Update 04-cdisc-submission-conformance.qmd --- .../04-cdisc-submission-conformance.qmd | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd index a50e16daf..7bd818b2c 100644 --- a/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd +++ b/user_guide/11-metadata-import/04-cdisc-submission-conformance.qmd @@ -459,9 +459,11 @@ for status, count in sorted(s["status_counts"].items()): print(f" {status:20s}: {count}") ``` -#### Issues +The `status_counts` dictionary enumerates every status bucket CORE reported. Use it to build +pass/fail thresholds or trend charts across multiple submission runs. -`issues()` returns one record per (dataset, rule) pair that reported at least one issue: +**Issues.** `issues()` returns one record per (dataset, rule) pair that reported at least one +issue. Pass a `status` filter to separate conformance violations from execution errors: ```{python} issues = report.issues() @@ -475,8 +477,6 @@ for issue in issues[:3]: print(f" Issues: {issue['issues']}") ``` -Filter by status to separate conformance problems from execution errors: - ```{python} from pointblank.metadata._cdisc_core import STATUS_ISSUE, STATUS_ERROR @@ -487,9 +487,12 @@ exec_errors = report.issues(status=STATUS_ERROR) print(f"Execution errors: {len(exec_errors)}") ``` -#### Row-Level Findings +Execution errors (`STATUS_ERROR`) indicate that CORE could not evaluate a rule, typically because +a required variable or dataset was absent or had an unexpected format. They are distinct from +conformance issues (`STATUS_ISSUE`) where the rule ran successfully but found a violation. -`findings()` returns the row-level detail from CORE's `Issue_Details` section: +**Row-level findings.** `findings()` returns the row-level detail from CORE's `Issue_Details` +section, giving the exact record that triggered each rule: ```{python} findings = report.findings() @@ -505,9 +508,10 @@ print(f"Variables: {f.variables}") print(f"Values: {f.values}") ``` -#### Per-Rule Run Results +Each finding carries the `USUBJID`, the affected variables and their values, and the 1-based row +number in the source dataset, matching the information shown in the built-in engine's findings table. -`rules()` returns one `CoreRuleResult` per rule with its run status: +**Per-rule run results.** `rules()` returns one `CoreRuleResult` per rule with its run status: ```{python} from pointblank.metadata._cdisc_core import STATUS_SUCCESS, STATUS_SKIPPED @@ -524,10 +528,15 @@ for r in failing: ``` Rules marked skipped are not failures. CORE skips rules when the dataset or variable they check is -absent from the submission. +absent from the submission. A skipped rule is equivalent to `not_applicable` in the built-in engine +and should not be treated as a conformance problem. ## Exporting Reports +Both built-in and CORE reports can be exported for archiving, sharing with a biometrics team, or +loading into a review tool. Two formats are supported: JSON for round-trippable machine-readable +output, and Excel for spreadsheet-based review workflows. + ### JSON `to_json()` saves the report as a JSON file. For CORE reports the structure mirrors CORE's native @@ -546,6 +555,9 @@ with tempfile.TemporaryDirectory() as tmp: print(f"Rules after round-trip: {len(reparsed.rules)}") ``` +The round-trip fidelity means a JSON file written by Pointblank can be re-loaded later without +re-running CORE, which is useful for archiving the state of a submission at a specific point in time. + ### Excel `to_excel()` writes the report as a workbook. Requires `openpyxl`: @@ -560,7 +572,9 @@ and `Conformance_Details`. ## Using Both Engines Together -The built-in engine provides fast feedback during development; CORE is the final gate: +The built-in engine provides fast feedback during development; CORE is the final gate. The typical +workflow is to iterate with the built-in engine until it reports a clean result, then run CORE once +as a pre-submission check: ```{python} #| eval: false @@ -590,6 +604,10 @@ if not core_report.all_passed(): print("Submission passed all conformance checks.") ``` +This pattern catches the majority of conformance problems early (when fixing them is cheap) and +reserves the slower CORE invocation for the final verification step. Both reports can be exported +and archived alongside the submission package. + ## Running the Integration Tests Pointblank ships integration tests for the full CORE pipeline. They are skipped automatically when @@ -604,3 +622,7 @@ export POINTBLANK_CDISC_CORE="python /path/to/core.py" export POINTBLANK_CDISC_CORE_CWD="/path/to/cdisc-rules-engine" pytest -m cdisc_core ``` + +The integration tests validate the full subprocess pipeline end-to-end, including XPT materialization, +CORE invocation, JSON parsing, and report construction. Running them against your local CORE +installation is a good sanity check after upgrading either Pointblank or the CORE engine.
    DatasetRuleIssuesMessage
    {item.dataset}{item.rule_id}