Sapling SAP
Reference

The ADaM from the SAP

The plan derives each ADaM variable once; the specification, the programs and the data follow.

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#

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 kindThe plan writesIt guaranteesRSAS
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 runsma_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 valuessma_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 valuemutate(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 datederive_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 isderive_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 valuederive_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 valuederive_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 summaryderive_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 conditionY 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 conditionmutate(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 readY 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 errormutate(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 recordderive_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 groupmutate(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 itselfcount(), 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="..."} rowsPARAMCD 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 runderive_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"} rowsa 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"} rowsa record of a listed VISIT gets its analysis visit; another visit (unscheduled) gets nonederive_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 isderive_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 limitsderive_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 staybind_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 recordsderive_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 recordderive_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 recordsderive_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 valuesderive_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 orderthe 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 othersmutate(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 periodderive_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 disagreederive_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 namedsma_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 kindOptionValuesDefault
rowswherea conditionevery record
rowskeysvariablesUSUBJID for ADSL; a BDS data set needs them
rowsorderevery variable of the data setthe order of the plan
copyfroma domain or an ADaM data setthe source of the rows
derive.valuetypeC, N, datethe type of the rule
derive.valuelabel, lengthtext, numberthe standard label; the longest value
derive.dateimputefirst, 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.datelevelday, monthday: only a missing day is imputed (a date without its month stays missing)
derive.datenot-before, not-aftera date variable of the recordno bound
derive.dateflag<VAR>Fno flag
derive.from.onewherea condition on the records of from=every record
derive.from.extremefirst, lastthe sort keys (expressions; desc X for descending)none: one record (derive.from.one)
derive.from.extremewherea conditionevery record
derive.from.summarywherea conditionevery record
flag.conditionfalseN, blankN
flag.teaevariable, date, startthe flag; the start date of the event; the first day of the periodADAE.TRTEMFL; ASTDT; TRTSDT
flag.teaeenda date and a number of days (TRTEDT + 30), a date, or nonerequired
flag.teaemissingemergent, not emergent, opennot emergent (a warning: the plan does not say it)
flag.anyfalseN, blankN
flag.extrememodefirst, lastnone (a flag of the condition)
flag.extremebyvariablesUSUBJID
flag.extremeordersort keys (desc X)none: required
flag.extremefalseN, blankblank
cutcodesa variablenone
poolminimum, into, bya number, a value, a variablerequired
paramscodethe test code of the datathe PARAMCD itself
paramsselecttrueevery record (a test the parameters do not list stops the run)
paramsderivedtruea parameter of the data
windows.daydaya variableADY
windows.dayasa data set with windowsnone
baselinebyvariablesUSUBJID; PARAMCD
baselineflaga flagABLFL
changerecordsa conditionevery record
imputerecordsa conditionevery record
imputeflaga flagANL01FL
indicatorlow, highfactors of the limits1 (the normal range)
indicatorlower, uppervariablesA1LO, A1HI
indicatorfallbacka collected indicator (LBNRIND)none
recordssetNAME = value; ...required
relchangeprefixtext_
relchangelimitnumber1 (half the range)
relchangedigitsnumber1
relchangeordervariablesVISITNUM; ADT; LBSEQ
relchangeseparatelya conditionnone: one sequence of records
parameterbyvariablesthe subject (and the variables of ADSL)
parametersetNAME = value; ...none
ttestarta date of the data setSTARTDT
ttefrom (a row)an ADaM data set made beforethe records of the subjects (the rows of the data set)
ttepriority (an event row)variables of its recordsnone: the order of the data
ttecensora number1
tte.periodstart, enddates of the records of the subjectsstart: STARTDT; end: required (it makes the period)
tte.periodstart_time, time (an event row)times (numbers: seconds since midnight)none: an event on the start day counts
tte.periodfrom (a row)an ADaM data set made beforethe records of the subjects (the rows of the data set)
tte.periodpriority (an event row)variables of its recordsnone: the order of the data
tte.periodcensor (a censoring row)a number1
tte.periodend_desc, end_censora text; a number"End of at-risk period"; 1
tte.orderedmodeearliest, orderedearliest (the earliest event, else the latest censoring)
tte.orderedcensora number1
numericbelowhalf, limit, missingnone: the statistician states it (an error of the plan without it)
numericabovelimit, missingnone: the statistician states it (an error of the plan without it)
numericflaga variableno flag
periodperiodsvalue: START - END; ...required
periodfroma data set with one record per subjectADSL (for the dates that the data set does not have)
periodtext, namesa variable; one name per periodnone
computedbyvariablesAVISIT; AVISITN (those the data set has), with the subject (STUDYID, USUBJID, the copies of ADSL)
computedrecordsa conditionevery record
computedsetNAME = value; ...none
icefrom, ata domain; the date of a recordrequired
icestart, enda date of the subject (TRTSDT, TRTEDT + 7)no bound
icedatea variablerequired
customcustom, r, sasa hook id; an R expression; a SAS expressionone 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 kindWhy a pattern
flag.teaeone 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.periodthe 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.orderedthe 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
numericit 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
perioda 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
computedthe 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
icethe 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 kindFixtureThe edge cases it hits
rowswhere with a missing valuea record with a missing ARMCD is kept by ARMCD != "SCRNFAIL"; the screen failure is dropped
copycopy by subjectan adverse event of a subject who is not in ADSL gets missing values; a variable copied under another name
derive.valuearithmetic and functionsa 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.valuedate functionsan 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.casescasesoverlapping cases (the first wins); a missing value falls to otherwise, or to missing without otherwise
derive.datepartial datesa 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.dateimputation clamped to the first dose and the end of studya 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.studydaystudy daythe day before the reference is -1; the reference is day 1; a missing reference
derive.from.oneone recorda subject without the record of visit 3; a subject without any record of SV
derive.from.extremefirst and lasta 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.summarysum, mean, countmissing values among the records; every value missing; a subject without records
flag.conditionflagsa missing value in the condition; any QS() with and without a record
flag.teaethe period of 30 days after the last dosean 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.anyflag of any recorda missing value of the condition; a subject without records
flag.extremefirst and last record of a visita 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
cutboundariesthe values on each boundary; a missing value
poolpooled sitesa site with a small group; a site without a subject in a group
paramsparameters with a code and selecta code mapped to another PARAMCD; a test that is not listed is left out
windows.daywindowsthe days on each boundary; a day between two windows; a missing day
windows.visitvisitsan unscheduled visit
baselinebaselinea group without a baseline record; a baseline record with a missing value
changechangethe baseline record is not a record of the change; a missing value; a baseline of 0; a negative baseline
imputeLOCFtwo windows filled from one record; a window between two analysed records; baseline only (nothing carried)
indicatorthreshold rangethe values on each threshold; a missing limit; no limits; the fallback; a missing value
recordsend of treatmenta subject with the record; a subject without it
relchangerelative changethe first record; a change above the limit; a record without a scheduled visit
parametermean of visitsa missing value among the records; a subject without records in the range; every value missing
ttefirst eventtwo 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
tteseveral rowsan 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.periodat-risk periodan 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.periodwithout a censoring rowtwo 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.orderedcensoring rules in orderno 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)
numericlimits of quantificationa number; "<5" and "<= 0.5" (half, the limit); ">100" (the limit, missing); a word; a blank; a negative number; ".5"
periodtwo periodsbefore 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
computedbody mass indexa visit with both parameters; a visit with one; a height of 0 (division by zero); a missing weight
icerescue medicationa 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
customcustom codea 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#

sapc adam: the products#

node src/cli.ts adam examples/cdiscpilot01/pilot-adam.qmd --out examples/cdiscpilot01/out/adam

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):

ProofResult
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 dashboardsee 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#