This page is the repository's own document docs/adam-from-sap.md, shown as it is, so that it never drifts from the product.
The SAP names its variables by the ADaM standard and derives them from SDTM; the ADaM specification, its programs and its data are products of the SAP.
Why#
An ADaM data set is built according to the SAP, and the SAP names ADaM variables (ADSL.EFFFL, ADQSADAS.CHG). Until
now Sapling checked the names against an ADaM specification that a programmer had written before (adam: adam under sapling:), and
the programs that made the ADaM were written by hand from that specification. The same decision (who is in the efficacy
population, which record is the Week 24 value, when an adverse event is treatment emergent) was written three times:
in the SAP, in the specification, in the program. Each copy can be wrong in another way.
Now the plan says it once. A chapter of the plan derives every ADaM variable the analyses read, from the SDTM data,
with the ADaM IG's names; sapc adam makes from it the specification, the Define-ready table and one R program per
data set. Plans with adam: dir under sapling: work as before.
SAP (.qmd) ──sapc check──► the analyses checked against the derivations of the same file
│
├──sapc adam──► specs/adam/*.json, codelists.json (the ADaM specification, r-agentic's format)
│ define.md, define.csv (the Define-ready table)
│ programs/*.R (admiral, one per data set; each step headed by its SAP section and protocol quote)
│ build_order.txt
│ │ dsr batch, on SDTM
│ ▼
│ the ADaM data (output/adam/*.xpt)
└──sapc build──► sap.md ──r-agentic's chain──► TLFs, ARD, CSR numbers ──► the study dashboard
The vocabulary: standards and SDTM#
- Variable names and labels: the ADaM IG v1.2 (
standards/adamig_v1.2_variables.csv: ADSL, BDS with time to event) and the ADaM OCCDS standard (standards/adam_occds_variables.csv: ASTDT, TRTEMFL, AOCCFL, CQzzNAM, ...). The numbered names are filled in:TRT01PNis "Planned Treatment for Period 01 (N)",ANL01FL"Analysis Flag 01". A variable the standards do not name needslabel=. - The SDTM data:
sdtm: folderundersapling:in the front matter, a folder of.xptfiles or its metadata filesdtm.json(sapc sdtm <folder> --out sdtm.json: every domain, every variable with its label, type, length, its longest value, and the values of the variables with at most 60 of them). The compiler checks each source variable against it; a copy takes its label, type and length from it; a short list of values becomes a codelist.examples/cdiscpilot01/sdtm/sdtm.jsonis the metadata of the pilot's SDTM (r-agenticdata/sdtm). - No ADaM specification is an input. It is an output. A plan has either
adam: dirundersapling:(the specification a programmer wrote, as before) or the derivations (dataset...), not both.
The commands#
One fact in one place: each variable is made by one command; a second one is an error. Each command takes its citation like any block of a plan: a citation after it, or after the heading of its section (the copies, the data set and its parameters need none). The commands are made in the order of the plan, data set by data set: a variable is used after the line that makes it (the compiler checks it).
Each kind of step has one entry in the registry of the step kinds (src/adam_steps.ts): what the statistician
writes, its options with their stated defaults, what it guarantees about the result (ties, missing values, a subject
without a record, a record on a boundary), its R and its SAS rendering, and a fixture of made-up records with the
expected records. The tables below are made from it.
| Step kind | The plan writes | It guarantees | R | SAS |
|---|---|---|---|---|
| rows: the records of a data set | ::: {.dataset name="ADSL" ... adam-class="ADSL" from="DM" where='ARMCD != "Scrnfail"' keys="USUBJID"} | the records of the source that meet where=, in their order; a comparison with a missing value is false, so a record with a missing value is kept by X != "a" and dropped by X = "a"; the data set is sorted by its keys at the end (a missing value first, as SAS sorts); two records with the same keys stop the run | sma_sdtm() or sma_adam_read(), filter() (admiral: none (dplyr::filter)) | a DATA step with a subsetting IF |
| copy: variables copied as they are | ::: {.copyvars dataset="ADAE" variables="STUDYID; TRTA = TRT01A; AGE" from="ADSL"} | a variable of the source of the rows keeps its value; NEW = OLD copies under another name; from another data set: by USUBJID, one record per subject there (two stop the run); a subject without a record there gets missing values | sma_map_source() (r-agentic) (admiral: derive_vars_merged() (inside sma_map_source())) | a DATA step (a new name); %merge_subject (another data set) |
| derive.value: a value of one record | ::: {.derive variable="ADSL.BMIBL" rule="round(WEIGHTBL / (HEIGHTBL / 100) ** 2, 1)"} | arithmetic with a missing value gives a missing value; a division by zero gives a missing value; round(x, d) rounds half away from zero, as SAS ROUND (1.45 is 1.5, -1.45 is -1.5); max() and min() take the values that are not missing (as SAS MAX and MIN); missing when every value is; days(from, to) is to - from + 1 (both days counted) when to is on or after from, and to - from when it is before (two different days never have 0 days, as admiral compute_duration); missing when a date is; months(from, to) is days(from, to) / 30.4375 (an average month: 365.25 / 12 days); year(x) and month(x) of a date or of an ISO 8601 text (a partial date "2024-03" has its year and month; "2024" has no month); samemonth(x, y) holds when both have a year and a month and they are the same (false when one has no month) | mutate() (admiral: none (dplyr::mutate)) | a DATA step (arithmetic only when its values are there) |
| derive.cases: a value by cases | ::: {.derive variable="ADSL.TRT01PN" rule='0 if ARMCD = "Pbo"; 54 if ARMCD = "Xan_Lo"; 81 otherwise'} | the first case whose condition holds gives the value; a condition with a missing value is false (X != "a" is true); without otherwise, a record for which no case holds gets a missing value | mutate(case_when()) (admiral: none (dplyr::case_when)) | a DATA step: IF ... ELSE IF |
| derive.date: a date of an ISO 8601 text, with the imputation of a partial date | ::: {.derive variable="ADAE.ASTDT" rule="date(AESTDTC)" impute="first" flag="ASTDTF"} | a complete date (and the date of a date-time) is the date; a text without a year is missing; with impute=: a missing day is the first, the 15th or the last day of the month; with level="month" a missing month and day are 1 January, 30 June or 31 December; not-before= and not-after= clamp an imputed date to a date of the record only when that date lies among the dates the partial date can be (admiral min_dates, max_dates): the parts of the date that are known never change; a complete date is never changed; the flag is D when the day was imputed, M when the month was; missing for a complete date | derive_vars_dt() (admiral: derive_vars_dt(highest_imputation, date_imputation, min_dates, max_dates)) | a DATA step: INPUT of the text, MDY and INTNX for the imputed day, the clamp as two IF statements |
| derive.studyday: the study day of a date | ::: {.derive variable="ADQSADAS.ADY" rule="studyday(ADT, TRTSDT)"} | on or after the reference: date - reference + 1; before it: date - reference (no day 0); missing when a date is | derive_vars_dy() (admiral: derive_vars_dy()) | a DATA step |
| derive.from.one: the value of the one record of another domain | ::: {.derive variable="ADSL.TRTSDT" rule="date(SVSTDTC)" from="SV" where="VISITNUM = 3"} | each subject has at most one record of from= that meets where= (two stop the run: write first= or last=); a subject without one gets a missing value | derive_vars_merged() (admiral: derive_vars_merged(check_type = "error")) | a DATA step on the records, then %pick(unique=yes) |
| derive.from.extreme: the value of the first or last record of another domain | ::: {.derive variable="ADSL.TRTEDT" rule="date(EXENDTC)" from="EX" last="date(EXSTDTC); EXSEQ"} | the records are sorted by the keys; a missing key sorts after every value (so first= never takes a record with a missing key while another has one, and last= takes it); records equal on every key keep their order in the data (the first of them for first=, the last for last=); a subject without a record gets a missing value | derive_vars_merged(order = , mode = ) (admiral: derive_vars_merged(order, mode)) | a DATA step on the records, then %pick(order=, which=) |
| derive.from.summary: a summary of the records of another domain | ::: {.derive variable="ADSL.CUMDOSE" rule="sum(EXDOSE)" from="EX"} (sum, mean, count, any, all) | the rule is one summary of the records of the subject: sum (missing when every value is missing, as SAS SUM), mean (of the values that are not missing; missing when every value is), count of the records that meet a condition, any and all (1 or 0); a subject without a record (after where=) gets a missing value, for every summary | derive_vars_merged_summary() (admiral: derive_vars_merged_summary()) | a DATA step on the records, then %summarize(stat=sum|mean|max|min) |
| flag.condition: a flag of a condition of the record | ::: {.flag variable="ADSL.SAFFL" condition='ITTFL = "Y" and TRTSDT is not missing'}; any QS(...) in the condition | Y when the condition holds, else N (false="blank": missing); a comparison with a missing value is false; any DOMAIN(condition) holds when the subject has a record of the domain that meets the condition | mutate(if_else()) (admiral: derive_var_merged_exist_flag() for any DOMAIN()) | a DATA step (%flag_if_any first for any DOMAIN()) |
| flag.teae: the treatment-emergent flag of the period of a treatmentemergent block | ::: {.treatmentemergent #TEAE variable="ADAE.TRTEMFL" start="TRTSDT" end="TRTEDT + 30" missing="emergent"} in the safety part of the plan (docs/markdown.md, "Adverse event tables"): the one definition the adverse event tables and the derivation read | Y when the first day of the period is not missing and the event starts on or after it and on or before the end (both ends in); a subject without the date of the end (still on treatment) has no end yet; N otherwise (also for a subject without a first dose); an event with a missing start date is Y with missing="emergent" (the worst case), N with "not emergent" or "open"; a partial date counts as missing unless the plan imputes it before (impute= of the date); the flag is placed at the first line of the data set where its dates are made or copied, so a later flag can read it (AOCCFL); a second derivation of the same flag is an error | mutate(if_else()) of the condition of the period (as flag.condition) (admiral: derive_var_trtemfl() states the same rule (start_date, end_window); the R program writes the condition with mutate(if_else())) | a DATA step (as flag.condition) |
| flag.any: a flag of a record of another domain | ::: {.flag variable="ADSL.SERFL" condition='AESER = "Y"' from="AE"} | Y when the subject has a record of from= that meets the condition; else N (false="blank": missing), also for a subject without any record | derive_var_merged_exist_flag() (admiral: derive_var_merged_exist_flag()) | %flag_if_any |
| flag.extreme: a flag of the first or last record of a group | ::: {.flag variable="ADQSADAS.ANL01FL" condition="AVISITN is not missing" mode="first" by="USUBJID; PARAMCD; AVISITN" order="AWTDIFF; ADY"} | among the records that meet the condition, Y on the first (last) of each group in the order of the keys; a missing key sorts after every value; records equal on every key keep their order in the data; the other records are blank (false="N": N, also those that do not meet the condition) | restrict_derivation(derivation = derive_var_extreme_flag), the row number as the last key (admiral: restrict_derivation(derive_var_extreme_flag)) | %flag_first (and a DATA step for false="N") |
| cut: groups of a number | ::: {.cut variable="ADSL.AGEGR1" source="AGE" groups="<65; 65-80; >80" codes="AGEGR1N"} | a-b holds both ends, a-<b not the upper one, <a, >a, >=a; the first group that holds; a missing value (or one in no group) has no group | mutate(case_when()) (admiral: none (case_when)) | a DATA step: IF ... ELSE IF |
| pool: pooled groups | ::: {.pooling variable="ADSL.SITEGR1" source="SITEID" by="TRT01PN" minimum="3" into="900"} | a value of source= with fewer than minimum= subjects in any group of by= (a group without a subject counts as 0) is into=; else the value itself | count(), tidyr::complete(), derive_vars_merged() (admiral: none (count, derive_vars_merged)) | %pool |
| params: the parameters of a BDS data set | ::: {.parameters dataset="ADQSADAS" from="QS.QSTESTCD" value="QS.QSSTRESN"} with [Name]{.param paramcd="ACTOT" n="1" code="..."} rows | PARAMCD is the test code, or the PARAMCD of the row whose code= it is; PARAM and PARAMN are those of the row; select="true" keeps only the records of the listed tests; without it, a record of another test stops the run | derive_vars_param(spec$params) (r-agentic) (admiral: none (derive_vars_param of r-agentic)) | a DATA step: SELECT (an ERROR line for a test not listed) |
| windows.day: analysis windows by the day | ::: {.windows datasets="ADQSADAS" by="day"} with []{.window avisit="Week 8" avisitn="8" range="2-84" target="56"} rows | a day in a window (both ends in; <=a, >a open) gets it: the first window in the order of AVISITN that holds the day; a missing day, or a day in no window, gets no window; AWTDIFF = |day - target| | derive_vars_window(spec$windows) (r-agentic) (admiral: none (derive_vars_window of r-agentic)) | %windows with the windows as the rows of its call (# between them) |
| windows.visit: analysis visits by the visit | ::: {.windows datasets="ADLBC" by="visit"} with []{.window avisit="Week 2" avisitn="2" range="WEEK 2"} rows | a record of a listed VISIT gets its analysis visit; another visit (unscheduled) gets none | derive_vars_visit(spec$visits) (r-agentic) (admiral: none (derive_vars_visit of r-agentic)) | %visits with the visits as the rows of its call (# between them) |
| baseline: the baseline value | ::: {.baseline variable="ADVS.BASE" source="AVAL" by="USUBJID; PARAMCD; ATPTN"} | the value of the baseline record of the group is on every record of the group (also the baseline record); a group without a baseline record, or whose baseline value is missing, has it missing; one baseline record per group (two stop the run) | derive_var_base() (admiral: derive_var_base()) | %baseline |
| change: the change and the percent change from baseline | ::: {.changefrombase variable="ADQSADAS.CHG" rule="AVAL - BASE" records="AVISITN > 0"}; 100 * (AVAL - BASE) / abs(BASE) | on the records that meet records= (the others missing): AVAL - BASE; the percent change 100 * (AVAL - BASE) / |BASE| is missing when BASE is 0; missing when AVAL or BASE is | derive_var_chg(), derive_var_pchg() (in restrict_derivation()) (admiral: derive_var_chg(), derive_var_pchg()) | a DATA step |
| impute: the last observation carried forward (LOCF) into the analysis windows | ::: {.impute dataset="ADQSADAS" method="LOCF" records='PARAMCD = "ACTOT"'} | for each window after the first window after baseline that a group has no analysed record in, a copy of the last analysed record after baseline before it, moved to the window (AVISIT, AVISITN, AWLO, AWHI, AWTARGET, AWRANGE; AWTDIFF from the day of the copied record), with DTYPE = "LOCF"; baseline is never carried; a window after the last analysed record of the group is filled, a window before the first is not; the windows are a baseline window and at least two after it (fewer: an error of the plan) | derive_locf_windows() (r-agentic) (admiral: none (derive_locf_records fills the visits of a reference data set and carries baseline too)) | %locf |
| indicator: a reference range indicator | ::: {.rangeindicator variable="ADLBC.ANRIND" value="AVAL" low="0.5" high="1.5" fallback="LBNRIND"} | L when the value is below low x the lower limit, H when above high x the upper limit, else N; a missing limit does not count; a value without any limit is N; a missing value has no indicator; with fallback=, a record without a value or limits takes H when the collected indicator is ABNORMAL, and N when it is NORMAL and the record has no limits | derive_var_anrind_range() (r-agentic) (admiral: none (derive_var_anrind has no factors and no fallback)) | a DATA step |
| records: new records copied from records with other values | ::: {.addrecords dataset="ADLBC" records='AENTMTFL = "Y"' set='AVISIT = "End of Treatment"; AVISITN = 99'} | each record that meets the condition is copied once, with the values of set= (a column made before); the records already there stay | bind_rows() (admiral: none (bind_rows)) | a DATA step with two OUTPUT statements |
| relchange: parameters of the change from the record before, relative to the normal range | ::: {.relativechange dataset="ADLBC" value="LBSTRESN" separately="AVISITN is not missing"} | one new record for each record: PARAMCD = prefix + PARAMCD; on a scheduled visit (AVISITN not missing) AVAL = (value - the value of the record before in the order) / ((A1HI - A1LO) / 2), rounded as SAS ROUND; missing for the first record of a group; ANRIND is H above +limit, L below -limit, else N (missing when AVAL is); the limits, BASE, CHG and the ratios are missing on the new records | derive_param_relative_change() (r-agentic) (admiral: none (the pilot SAP 8.3)) | %relchange |
| parameter: a parameter that summarises records | ::: {.parameter parameter="ADQSNPIX.NPTOTMN" rule="mean(AVAL)" records='PARAMCD = "NPTOT" and AVISITN >= 4' by="BASE" set='AVISIT = "Weeks 4-24"; AVISITN = 98'} | one new record for each subject (and by= group) that has records meeting records=; AVAL is the rule over those records: sum, mean (of the values that are not missing), count, any, all; a subject without such records gets no record | derive_summary_records() (admiral: derive_summary_records()) | %summarize_groups |
| tte: a time to event: the earliest event, else the latest censoring | ::: {.timetoevent parameter="ADTTE.TTDE" start="STARTDT"} with []{.event records='...' from="ADAE" date="ASTDT" set='...'} and []{.censor records="" from="ADSL" date="RFENDT" censor="1"} | every subject of the data set gets one record of the parameter: the earliest date among the records of the event rows (CNSR = 0); else the latest date among the censoring rows (CNSR = its censor=); two events on one date: the earlier row, then priority= of the row (a missing value last), then the order of the data; two censorings on one date: the later row; a record whose date is missing is no event and no censoring; a subject without an event and without a censoring date gets no record; AVAL = ADT - start + 1 (days, both counted); a date before the start gives ADT = the start (admiral); a subject without a start date has AVAL missing; a data set may have several parameters of time to event: each its own records | derive_param_tte() (admiral: derive_param_tte(event_source, censor_source)) | %tte (one call per parameter: several rows are first stacked in DATA steps, one per row) |
| tte.period: a time to event in an at-risk period: the first event in the period, else the censoring in it, else the end of the period | ::: {.timetoevent parameter="ADTTE.DIL3X" start="TRTSDT" end="DCUTDT"} with []{.event from="ADLB" records='PARAMCD in ("ALT", "AST") and R2A1HI >= 3' date="ADT" priority="PARAMCD" set='EVNTDESC = "ALT/AST >= 3x ULN"'} and []{.censor date="DCUTDT" set='EVNTDESC = "Cutoff date"'} | the at-risk period of a subject is from start= to end= (both dates of the subject); a subject without a start date or without an end date gets no record and is reported by name (R: a message "[tte] ..." of the program, which stays CLEAN; SAS: a line "WARNING: [tte] ..." of the macro); a subject whose start is after its end gets no record; an event is a record of an event row that meets its condition, with its date after the start and on or before the end, or on the start day; on the start day, with start_time= and the time= of the row both there, only when the event time is not before the start time; the first event of the subject gives ADT and CNSR = 0: the earliest date; on one date the earlier row, then priority= of the row (a missing value last), then the order of the data; without an event: the latest date of the censoring rows inside the period (both ends in; on one date the later row), with its censor= and set=; without one: the end of the period with CNSR = end_censor= and EVNTDESC = end_desc=; AVAL = ADT - start + 1; EVNTDESC, SRCDOM, SRCVAR and SRCSEQ of the record taken (the data set, its date variable, its ASEQ or --SEQ; for a censoring of the subject records and the end: the data set of the subjects and the date variable); ADTF the flag of the event date (<date>F) when its data set has one; ATM and ATMF with time=; set= of a row gives other values | derive_param_tte() on the subjects with a period, bind_rows() of the end of the period (admiral: derive_param_tte(event_source, censor_source) with the period in the filters; the end of the period added for the subjects without a record) | %tte (one call per parameter: the start, the end, the censoring and the end of the period are its parameters) |
| tte.ordered: a time to event by a table of rules in order (the censoring rules of an oncology plan) | ::: {.timetoevent parameter="ADTTE.PFS" start="RANDDT" mode="ordered"} with rows []{.censor records="LASTADT is missing" date="RANDDT" censor="1" set='EVNTDESC = "..."'} ... []{.event records="PDDT is not missing" date="PDDT"} ... in their order | the rows are read in their order: the first row whose condition holds for the subject and whose date is not missing gives ADT (its date=), CNSR (0 for an event, its censor= for a censoring) and its set= values; a condition and a date are of the subject (the rows of the data set): the dates of other domains are derived before (derive from= first=, last=); "two or more missed assessments" is a condition on two dates (days(LADTPD, PDDT) > 91); a subject for whom no row holds gets no record (end the table with a row without a condition); AVAL = days(start, ADT) | mutate(case_when()) on the records of the subjects, derive_vars_duration() (admiral: derive_extreme_event() (events in an order); here case_when() on the subject records) | a DATA step: IF ... ELSE IF in the order of the table |
| numeric: a character result as a number, with the values outside the limits of quantification | ::: {.numeric variable="ADLB.AVAL" source="LBSTRESC" below="half" above="limit" flag="AVALQFL"} | a text that is a number (5.3, -1, .5, blanks around it) is that number; "<x" or "<=x" (blanks allowed) is x / 2 (below="half"), x (below="limit") or missing; ">x" or ">=x" is x (above="limit") or missing; any other text (NEGATIVE, TRACE, a range, a blank) is missing; the flag is Y on a "<x" or ">x" value whatever the rule gives, and missing on the others | mutate(case_when(grepl(...))) (admiral: none (admiral has no text-to-number rule for limits of quantification)) | a DATA step: PRXMATCH, INPUT |
| period: the period (or phase) of a record | ::: {.period variable="ADAE.APERIOD" date="ASTDT" periods="1: TR01SDT - TR01EDT; 2: TR02SDT - TR02EDT" text="APERIODC" names="Period 01; Period 02"} | a record whose date is on or after the start and on or before the end of a period (both ends in) gets that period; when two periods hold the date (the end of one is the start of the next), the first of the list; a missing end: the period goes on; a missing start: the period does not apply to the subject; a missing date, or a date in no period, gives no period | derive_vars_merged() of the dates, mutate(case_when()) (admiral: derive_vars_joined() with a period reference data set (create_period_dataset()); here case_when() on the dates of ADSL) | %merge_subject of the dates, a DATA step: IF ... ELSE IF |
| computed: a parameter computed from other parameters of the same subject and visit | ::: {.computed parameter="ADVS.BMI" rule="WEIGHT.AVAL / (HEIGHT.AVAL / 100) ** 2" by="AVISIT; AVISITN"} | one new record for each subject and group in which every parameter of the rule has a record (after records=) and every value the rule reads is there; none otherwise; two records of one parameter in a group stop the run (choose one with records=); AVAL is the rule (a division by zero is missing) | derive_param_computed() (admiral: derive_param_computed()) | %param_wide, then a DATA step of the rule |
| ice: an intercurrent event: its flag and its date in ADSL | ::: {.intercurrentevent variable="ADSL.RESCFL" records='CMCAT = "RESCUE"' from="CM" at="date(CMSTDTC)" start="TRTSDT" end="TRTEDT + 7" date="RESCDT"}; an estimand refers to it: [Rescue medication]{.intercurrent strategy="..." data='ADSL.RESCFL = "Y"'} | a record counts when it meets the condition and its date (at=) is not missing, on or after start= and on or before end= (both ends in; a missing bound is open); a partial date is missing, so it does not count (impute it before when the plan says so); the date is the earliest date of the records that count; the flag is Y when there is one, else N (also for a subject without any record); the flag and the date never disagree | derive_vars_merged(order = , mode = "first"), mutate() of the flag (admiral: none (admiral builds intercurrent events from derive_vars_merged() and derive_var_merged_exist_flag())) | a DATA step on the records, %pick, a DATA step of the flag |
| custom: custom code: a hook of the programmer, or one line of R or SAS in the SAP | ::: {.derive variable="ADSL.WTLOSFL" rule="the rule in words" custom="adsl_wtloss" type="C"} (the hook in custom/adsl.R and custom/adsl.sas), or r="..." and sas="..." | the SAP states the rule in words; the code is the programmer's (custom/<ds>.R for R, custom/<ds>.sas for SAS), bound to its step and pinned to the fingerprint of its generated input (a change of the SAP that it reads is DRIFT); a language without its hook (or its one line) does not make the variable: NOT MADE, with the missing hook named | sma_custom_apply(.custom, "add", ...) (r-agentic) or mutate() of r= (admiral: none) | %hook_add of custom/<ds>.sas, or a DATA step of sas= |
The options and their stated defaults (what a plan does not say, both engines do as written here):
| Step kind | Option | Values | Default |
|---|---|---|---|
| rows | where | a condition | every record |
| rows | keys | variables | USUBJID for ADSL; a BDS data set needs them |
| rows | order | every variable of the data set | the order of the plan |
| copy | from | a domain or an ADaM data set | the source of the rows |
| derive.value | type | C, N, date | the type of the rule |
| derive.value | label, length | text, number | the standard label; the longest value |
| derive.date | impute | first, middle, last (the old words "first day", "last day", "middle day" are read as first, last, middle) | no imputation: a partial date is missing |
| derive.date | level | day, month | day: only a missing day is imputed (a date without its month stays missing) |
| derive.date | not-before, not-after | a date variable of the record | no bound |
| derive.date | flag | <VAR>F | no flag |
| derive.from.one | where | a condition on the records of from= | every record |
| derive.from.extreme | first, last | the sort keys (expressions; desc X for descending) | none: one record (derive.from.one) |
| derive.from.extreme | where | a condition | every record |
| derive.from.summary | where | a condition | every record |
| flag.condition | false | N, blank | N |
| flag.teae | variable, date, start | the flag; the start date of the event; the first day of the period | ADAE.TRTEMFL; ASTDT; TRTSDT |
| flag.teae | end | a date and a number of days (TRTEDT + 30), a date, or none | required |
| flag.teae | missing | emergent, not emergent, open | not emergent (a warning: the plan does not say it) |
| flag.any | false | N, blank | N |
| flag.extreme | mode | first, last | none (a flag of the condition) |
| flag.extreme | by | variables | USUBJID |
| flag.extreme | order | sort keys (desc X) | none: required |
| flag.extreme | false | N, blank | blank |
| cut | codes | a variable | none |
| pool | minimum, into, by | a number, a value, a variable | required |
| params | code | the test code of the data | the PARAMCD itself |
| params | select | true | every record (a test the parameters do not list stops the run) |
| params | derived | true | a parameter of the data |
| windows.day | day | a variable | ADY |
| windows.day | as | a data set with windows | none |
| baseline | by | variables | USUBJID; PARAMCD |
| baseline | flag | a flag | ABLFL |
| change | records | a condition | every record |
| impute | records | a condition | every record |
| impute | flag | a flag | ANL01FL |
| indicator | low, high | factors of the limits | 1 (the normal range) |
| indicator | lower, upper | variables | A1LO, A1HI |
| indicator | fallback | a collected indicator (LBNRIND) | none |
| records | set | NAME = value; ... | required |
| relchange | prefix | text | _ |
| relchange | limit | number | 1 (half the range) |
| relchange | digits | number | 1 |
| relchange | order | variables | VISITNUM; ADT; LBSEQ |
| relchange | separately | a condition | none: one sequence of records |
| parameter | by | variables | the subject (and the variables of ADSL) |
| parameter | set | NAME = value; ... | none |
| tte | start | a date of the data set | STARTDT |
| tte | from (a row) | an ADaM data set made before | the records of the subjects (the rows of the data set) |
| tte | priority (an event row) | variables of its records | none: the order of the data |
| tte | censor | a number | 1 |
| tte.period | start, end | dates of the records of the subjects | start: STARTDT; end: required (it makes the period) |
| tte.period | start_time, time (an event row) | times (numbers: seconds since midnight) | none: an event on the start day counts |
| tte.period | from (a row) | an ADaM data set made before | the records of the subjects (the rows of the data set) |
| tte.period | priority (an event row) | variables of its records | none: the order of the data |
| tte.period | censor (a censoring row) | a number | 1 |
| tte.period | end_desc, end_censor | a text; a number | "End of at-risk period"; 1 |
| tte.ordered | mode | earliest, ordered | earliest (the earliest event, else the latest censoring) |
| tte.ordered | censor | a number | 1 |
| numeric | below | half, limit, missing | none: the statistician states it (an error of the plan without it) |
| numeric | above | limit, missing | none: the statistician states it (an error of the plan without it) |
| numeric | flag | a variable | no flag |
| period | periods | value: START - END; ... | required |
| period | from | a data set with one record per subject | ADSL (for the dates that the data set does not have) |
| period | text, names | a variable; one name per period | none |
| computed | by | variables | AVISIT; AVISITN (those the data set has), with the subject (STUDYID, USUBJID, the copies of ADSL) |
| computed | records | a condition | every record |
| computed | set | NAME = value; ... | none |
| ice | from, at | a domain; the date of a record | required |
| ice | start, end | a date of the subject (TRTSDT, TRTEDT + 7) | no bound |
| ice | date | a variable | required |
| custom | custom, r, sas | a hook id; an R expression; a SAS expression | one of them is required |
Why each newer kind is a pattern of its own (a kind of step), not a function of the rule language:
| Step kind | Why a pattern |
|---|---|
| flag.teae | one fact: the period is stated once, in words a statistician reads (its end, its missing start dates), and the derivation of the flag is made from it, so the tables, the ADaM specification and the programs cannot disagree |
| tte.period | the period is of the subject (another data set than the records of the events), and the rule for its ends (the start day, the time, the end of the period as a censoring of its own) is the same for every parameter: a pattern states it once |
| tte.ordered | the order of the rows is the meaning (the first rule that applies wins), which is a table, not an expression; each row is a condition and a date of one record, so the rule language states each part |
| numeric | it makes two variables (the number and its flag) from one text, and it needs a rule the statistician states; as a function the rule would hide in its arguments and the flag would read the text a second time |
| period | a period is a lookup of the date of a record in a table of dates of the subject (in another data set), with a rule for its ends and for two periods that meet; the rule language reads one record |
| computed | the rule reads several records (one per parameter) of a group, and the rule language reads one record; the pattern states which records, by what, and what happens when one is missing |
| ice | the flag and the date are one fact (one block makes both, so they cannot disagree), its time window is typed (start=, end=), and the estimands refer to it |
The fixtures (made-up records; test/adam_steps-r.test.ts runs each through sapc adam and the R engine and compares every record with the expected records; node tools/adam_steps_sas.ts does the same on the SAS server): 37 fixtures of 33 step kinds.
| Step kind | Fixture | The edge cases it hits |
|---|---|---|
| rows | where with a missing value | a record with a missing ARMCD is kept by ARMCD != "SCRNFAIL"; the screen failure is dropped |
| copy | copy by subject | an adverse event of a subject who is not in ADSL gets missing values; a variable copied under another name |
| derive.value | arithmetic and functions | a division by zero; a missing value; rounding half away from zero; max and min with a missing value; days() with the end before the start and with a partial date |
| derive.value | date functions | an adverse event that starts in the month of the first dose with a partial date (treatment emergent by "the same month"); a date without its month; months() before and after the first dose |
| derive.cases | cases | overlapping cases (the first wins); a missing value falls to otherwise, or to missing without otherwise |
| derive.date | partial dates | a complete date, a date-time, a missing day (first, last in a leap February, middle), a missing month (stays missing at level day), a missing text |
| derive.date | imputation clamped to the first dose and the end of study | a start in the month of the first dose (clamped to it); a start in another month (not clamped); a complete date before the first dose (never changed); a year only at level month (clamped, flag M); a bound outside the year |
| derive.studyday | study day | the day before the reference is -1; the reference is day 1; a missing reference |
| derive.from.one | one record | a subject without the record of visit 3; a subject without any record of SV |
| derive.from.extreme | first and last | a tie on the first key broken by the second; a missing key sorts last; a missing value of the record taken; a subject without records |
| derive.from.summary | sum, mean, count | missing values among the records; every value missing; a subject without records |
| flag.condition | flags | a missing value in the condition; any QS() with and without a record |
| flag.teae | the period of 30 days after the last dose | an event before the first dose; on the day of the first dose; on the last day of the period (TRTEDT + 30) and the day after; a missing start date (missing="emergent"); a subject without a first dose; a subject still on treatment (no TRTEDT) |
| flag.any | flag of any record | a missing value of the condition; a subject without records |
| flag.extreme | first and last record of a visit | a tie on the key (the order of the data decides); a missing key (sorts last: last= takes it); a record that does not meet the condition |
| cut | boundaries | the values on each boundary; a missing value |
| pool | pooled sites | a site with a small group; a site without a subject in a group |
| params | parameters with a code and select | a code mapped to another PARAMCD; a test that is not listed is left out |
| windows.day | windows | the days on each boundary; a day between two windows; a missing day |
| windows.visit | visits | an unscheduled visit |
| baseline | baseline | a group without a baseline record; a baseline record with a missing value |
| change | change | the baseline record is not a record of the change; a missing value; a baseline of 0; a negative baseline |
| impute | LOCF | two windows filled from one record; a window between two analysed records; baseline only (nothing carried) |
| indicator | threshold range | the values on each threshold; a missing limit; no limits; the fallback; a missing value |
| records | end of treatment | a subject with the record; a subject without it |
| relchange | relative change | the first record; a change above the limit; a record without a scheduled visit |
| parameter | mean of visits | a missing value among the records; a subject without records in the range; every value missing |
| tte | first event | two events (the earliest); an event before the start that the condition leaves out; a subject without an event; a missing event date; two parameters in one data set |
| tte | several rows | an event of two rows on one date (the earlier row); an event of the subject record (death); a serious event before the start (left out by the condition); two censoring rows (the latest; on one date the later row); a subject without a censoring date (no record) |
| tte.period | at-risk period | an event after the end (not an event); an event on the start day without times (an event); on the start day before the start time (not an event, the later event taken); on the start day after the start time (an event); two events on one date (priority= decides against the order of the data); an imputed event date (ADTF); no event with the censoring date in the period; with the censoring date after the end (the end of the period, its own description and code); an event before the start and no censoring date (the end); a subject without a start date (no record; reported); a start after the end (no record) |
| tte.period | without a censoring row | two event rows (an imputed date with its flag; the death of the subject record); the events of both rows on one date (the earlier row); an event after the end (the end of the period, end_desc=, end_censor=); a parameter whose rows read no subject record; two parameters in one data set; a subject without an end date (no record; reported) |
| tte.ordered | censoring rules in order | no adequate assessment (censored at randomisation); a new therapy before progression; progression after two or more missed assessments (censored at the last adequate assessment); progression; no event (the last adequate assessment) |
| numeric | limits of quantification | a number; "<5" and "<= 0.5" (half, the limit); ">100" (the limit, missing); a word; a blank; a negative number; ".5" |
| period | two periods | before the first period; on the day the first ends and the second starts (the first); between two periods; after the end; an open second period; a second period without a start; a missing date |
| computed | body mass index | a visit with both parameters; a visit with one; a height of 0 (division by zero); a missing weight |
| ice | rescue medication | a record before the first dose (does not count); two records that count (the earliest); a record of another category; a record after the window; a partial date; a subject without records |
| custom | custom code | a hook_add of the programmer in R and its twin in SAS; one line of R and of SAS |
The decodes of a codelist, in their order: ::: {.codelist codelist="SEX" values="F = Female; M = Male; U = Unknown"}
(or ::: {.codelist codelist="ADSL.SEX" values="..."}); the tables show the decode of a value the data give
(codelists.json).
Options of every variable command: label= (a name the standards do not have), length=, type= (when the rule
does not tell it), work (a work variable: used by later steps, not in the data set).
The rules#
rule := case (";" case)* 0 if ARMCD = "Pbo"; 54 if ARMCD = "Xan_Lo"; 81 otherwise
case := value ["if" condition | "otherwise"]
condition := and, or, not; = != < <= > >= (or eq ne lt le gt ge); X is [not] missing; X [not] in ("A", "B");
any DOMAIN(condition)
value := numbers, "texts", variables, missing, + - * / **, (...), functions
Functions: date(--DTC), days(from, to) (both days counted; two different days never have 0 days),
months(from, to) (days / 30.4375), year(x), month(x) (of a date or an ISO 8601 text, a partial one too),
samemonth(x, y), studyday(date, reference),
round(x, d) (half away from zero, as SAS), max(...), min(...), abs(x), coalesce(...), contains(x, "A|B"),
startswith(x, "_"); over records: sum(x) (missing when all are, as SAS SUM), mean(x), count(condition),
any(condition), all(condition).
Missing values as a SAS WHERE: a comparison with a missing value is false (AGE < 65 is false when AGE is
missing: no age group); X != "Y" is true when X is missing (a missing value is not "Y"). A case without otherwise
leaves the variable missing when no case holds. The generated R keeps this (%in%, case_when()), the way the study's
programs make R give the SAS numbers.
What the compiler checks#
- every variable an analysis, population, subset, grouping or model reads is derived or copied in the plan, else an
error with the fix: derive it in the plan:
::: {.derive variable="ADSL.EFFFL" rule="rule"}, or copy it:::: {.copyvars dataset="ADSL" variables="EFFFL" from="DOMAIN"}; - every quoted value against the codelist the derivations give (a block
cut's groups, a flag's Y/N, the parameters, the windows, the SDTM values of a copy):ADSL.AGEGR1 = ">=80"is an error; - every quoted value a derivation compares with a column of its SDTM rows against the values the data have
(warning
adam.value, with the values anddid you mean):ARMCD = "PLACEBO"when the data sayPBOis a condition that is never true. It is a warning, not an error: data that are not complete yet can lack a value; - every variable a derivation reads is a column of its rows or of its
from=domain, or made before the line (withdid you mean);any DOMAIN(...)against that domain; - every source against the SDTM metadata (
sapling: sdtm:); a data set used before its blockdataset; a variable made twice; a non-standard variable without a label; names longer than 8 (transport files); keys;order=that leaves a variable out; a BDS data set without parameters;impute=on something other than a date; LOCF without windows or an analysis flag; the derivations withoutsapling: sdtm:(a warning: nothing can be checked).
sapc adam: the products#
node src/cli.ts adam examples/cdiscpilot01/pilot-adam.qmd --out examples/cdiscpilot01/out/adam
specs/adam/<ds>.json,codelists.json: the specification in r-agentic's format (data set, label, structure, class, keys, program; per variable name, label, type, length, format, origin Predecessor/Derived/Assigned, source or method, codelist;sap: the plan's section;protocol: the protocol section and its words), with theparams,windowsandvisitslists r-agentic'sderive_vars_param(),derive_vars_window(),derive_vars_visit()read. Lengths: a text the length of its longest value (the SDTM data, the values the rule writes; FDA Technical Conformance Guide 4.1.4.1).define.md,define.csv: the Define-ready table, one row per variable (origin, source or derivation as the SAP writes it, codelist, SAP and protocol sections), and the codelists.programs/<ds>.R: one program per data set in r-agentic's conventions (sma_setup,sma_map_source, admiral,sma_finalize,sma_check_adam); each step is headed by the SAP section, the line of the plan, the command and the protocol's words. Nothing is typed in from the data: every value is the SAP's.build_order.txt: a data set after the data sets it reads.
The pilot: examples/cdiscpilot01/pilot-adam.qmd#
pilot-full.qmd (every analysis of the protocol and every table of the CSR) with adam: adam-full under sapling: replaced by
sdtm: sdtm under sapling: and a section "The analysis data sets" (about 400 lines): ADSL, ADAE, ADQSADAS, ADQSCIBC, ADQSNPIX,
ADVS, ADLBC, ADLBH, ADLBHY, ADTTE, 415 variables, 175 commands (and 4 codelists of decodes). Its structured SAP has the blocks of pilot-full's
(the same analyses, word for word; only the prose of the new section is new), so the chain makes the same TLFs.
The proofs (zsh tools/adam_from_sap.sh, zsh tools/sap_to_dashboard.sh):
| Proof | Result |
|---|---|
the generated specification against r-agentic's specs/adam (5 data sets) and adam-full/ (5, from the pilot define) | every variable and type the same (in the study's order); 303 differences of lengths, labels, origins, sources and codelists, each with its cause: SPEC COMPARE EXPLAINED (.scratch/adam_from_sap/spec_compare.txt) |
the generated programs on SDTM (a scratch copy of r-agentic, dsr batch) | 10 of 10 BATCH: CLEAN |
the ADaM data against the study's R ADaM (r-agentic programs/adam, programs/adam_full) | 10 of 10 data sets equal cell for cell (8,218,673 cells); ADAE's transport file byte for byte; ADAM FROM SAP IDENTICAL |
... against the SAS ADaM (data/adam_sas) | ADSL, ADAE, ADQSADAS, ADVS, ADLBC equal cell for cell |
... against the pilot ADaM (data/adam_ref) | ADQSCIBC, ADQSNPIX, ADLBH, ADLBHY, ADTTE: the same differences as the study's programs, each explained in r-agentic programs/adam_full/PROOF.txt (ADTTE equal) |
| the full chain on the generated ADaM, and the dashboard | see docs/full-sap-inventory.md, stage 4 |
The differences of the transport files are attributes only (lengths: the longest value; labels: the standard's), never values: the cell-by-cell comparison is the proof.
Limits and open issues#
- The generator knows the derivations of the commands above. A rule outside them (a rolling window, a derivation over
two data sets at once) is written in the SAP in words with
::: {.derive variable="..." rule="..." custom="<hook id>"}(orr="...") and its code in a hook ofcustom/<ds>.R; a programmer's adjustment of a generated step is a hook too (hook_override,hook_after, ...). The generated programs call the hooks;sapc adamreports the hooks an amendment of the SAP touches: customize.md. - The total ACTOT of the ADAS-Cog is scored in the SDTM data (QSTESTCD ACTOT); the plan says how (the protocol's 30%
rule) and takes AVAL from QSSTRESN. A study whose SDTM lacks the total would write it with
parameter. - The study's own conventions that differ from the standards are kept as differences, not copied: the lengths the pilot
define allots, its labels ("Subject Died?"), its shared codelist names (ARMN). A sponsor that wants them writes
length=andlabel=. sapc adamwrites R;sapling sas adam programswrites SAS from the same model, every step kind with its SAS (sas.md, "The ADaM data sets in SAS"). The SAS of the fixtures had no run on a SAS server yet (node tools/adam_steps_sas.ts):sapling sas adam runsays OPEN until it has.