This page is the repository's own document docs/sas.md, shown as it is, so that it never drifts from the product.
A study in Sapling has one analysis contract (CDISC ARS, out/tlf/spec/), made from the SAP. The R engine reads it and
makes the tables. sapling sas makes the same tables again in SAS, from the same contract and the same ADaM data, with
the listings and figures that go with them, and compares every number of SAS with the number of R.
Two engines that agree are an independent check: the SAS macros share no code with the R engine.
sapling build && sapling run # the SAP, the ADaM data and the tables of R (out/ard)
sapling sas programs # out/sas/programs/*.sas and out/sas/macros/ (no SAS needed)
sapling sas run # on the SAS server of DataStep Studio; the outputs come back to out/sas/
sapling sas check # every number of R against the number of SAS: out/sas/check.md
| Command | Verdict |
|---|---|
sapling sas programs [--no-companions] | SAS PROGRAMS PASS: n program(s) ..., with each analysis NOT MADE by SAS |
sapling sas run [OUTPUT] [--upload-data] [--flat] | SAS RUN PASS / FAIL: n program(s) ...: tables, listings, figures |
sapling sas check | SAS CHECK PASS / OPEN / FAIL: n of m numbers of R are the same in SAS |
A program#
A program reads like the table it makes, as a SAS programmer writes it by hand: the population and the records in DATA
steps, one call of a statistics macro for each block of the table, then %stack; the columns with their N
(%columns), every number as CSV (%ard), the rows as printed (%layout, which keeps the call of the printer in the
macro variable of setup=), the titles as plain statements, and the printer %print from that setup (it writes the
table as RTF and as a monospace listing).
%LET spl_root = ~/sapling/cdiscpilot01; * the one line to change on another computer;
%INCLUDE "&spl_root./macros/sapling.sas";
%study(CDISCPILOT01, program=t14_2_01)
/* Intent-to-treat population: one row per subject */
PROC SORT DATA=adam.adsl OUT=adsl;
WHERE ittfl = 'Y';
BY usubjid;
RUN;
%describe(data=adsl, var=age, by=trt01a, total=yes, label="Age (years)", out=t2_age_summ)
%anova(data=adsl, var=age, by=trt01a, label="Age (years)", out=t2_age_cmp)
%freq(
data = adsl
, var = agegr1
, by = trt01a
, levels = agegr1 '<65' '65-80' '>80'
, keep = agegr1n
, total = yes
, label = "Age category (years)"
, out = t2_agegr_summ
)
%freq(data=adsl, var=sex, by=trt01a, total=yes, label="Sex", out=t2_sex_summ)
%chisq(data=adsl, var=sex, by=trt01a, label="Sex", out=t2_sex_cmp)
%stack(t2_age_summ t2_age_cmp t2_agegr_summ t2_sex_summ t2_sex_cmp, out=t14_2_01)
/* The columns with their N, every number for sapling sas check, the rows as printed */
%columns(data=t14_2_01, n=adsl, var=trt01a, order=trt01an, total=yes, out=t14_2_01_cols)
%ard(data=t14_2_01, columns=t14_2_01_cols)
%layout(
data = t14_2_01
, columns = t14_2_01_cols
, var = trt01a
, file = &spl_out./tlf/t14_2_01
, setup = t14_2_01_print
, out = t14_2_01_display
)
TITLE1 "Table 14-2.01 Summary of Demographic and Baseline Characteristics";
TITLE2 "Population: Intent-to-treat population";
FOOTNOTE1 J=l "Program: t14_2_01.sas Output: T14_2_01";
%print(&t14_2_01_print.)
TITLE;
FOOTNOTE;
The rules of the design:
- A macro holds the procedure it runs.
%describeis a PROC MEANS,%freqa PROC FREQ,%ancovaand%anovaa PROC GLM,%lsmeansand%mmrma PROC MIXED,%kmand%logranka PROC LIFETEST,%chisq,%fisherand%cmha PROC FREQ. Open the macro and read, top to bottom, what is computed. Every option is a parameter with its default in the macro's header. - Data in, data out. A statistics macro reads
data=(withwhere=) and writesout=. Everyout=stays in WORK after the run: open it, change it with a DATA step, stack it, print it, plot it. The work data sets of a macro start with an underscore and its name (_freq_n) and are deleted at its end;debug=yeskeeps them. - The population and the records are in the program: a DATA step (or a PROC SORT with a WHERE), or
where=on the call. Nothing is set by an earlier call. - Groups come from the data.
by=trt01agives a group for each value of TRT01A: the treatments are never typed. The order of the columns is that of the numeric twin (order=trt01anon%columns).total=yesadds the group Total (all groups together) in the statistics macro;%columnsputs it last.levels=names the categories of the plan (they show also without a subject; a record of another category is not counted). - The N of a column (
N=86in its header) is counted by%columnsfrom the population (n=adsl): the subjects of each value of the column variable, all of them for the Total. Its result (t14_2_01_cols: COL, COLVALUE, BIGN, HEADERPlacebo|(N=86)) is a data set of the program, read by%ardand%layout. - The numbers come from the results, not from the page.
%ardwrites every number of the stacked results (and the N of each column) toard/<output>.csvbefore anything is printed;sapling sas checkreads it. - One printer: the owner's
%print(below). PROC REPORT is inprint.sasonly. A table is printed from its display data set (%layout: one row per printed line), a listing from its records (var=,by=). A figure is one call of its figure macro (%kmplot,%meanplot, ...: "Figures" below), under the TITLE and FOOTNOTE statements of the program. - A text is in quotes, a condition in parentheses:
label="Alzheimer's ...",where=(AESEV = 'SEVERE').
The result shape#
Every statistics macro writes the same columns, one row per group and statistic:
| Column | It holds |
|---|---|
| the group variables | as in the data: TRT01A, AEBODSYS, AEDECOD, PARAMCD, AVISIT ...; VERSUS: the group a difference is made with |
GROUPS | the names of the group variables of the row, in order (TRT01A AEBODSYS AEDECOD) |
BLOCK | the label of the block in a table |
STAT | n, pct, events, mean, sd, median, min, max, q1, q3, se, pval, estimate, lcl, ucl, lsmean, lsmean_se, diff, diff_se |
VALUE | the number, with every digit |
TEXT | the number as printed: its pattern, from stats= (mean="XX.X"; a statistic without a pattern has its default) |
keep= keeps more variables with the groups (the name of a parameter PARAM, the twins PARAMN, AVISITN for the
order of the rows). %stack adds RESULT (the result each row comes from) and ORD (its place in the list).
%columns, %ard, %layout and %forestplot read this shape; a DATA step of your own that writes it prints like any
result.
The macro library (sas/macros/)#
One macro per file, the file named as its macro, in the house standard of sas-style.md: a header box
(purpose, the procedure it holds, where it is proven, the parameters with their defaults, an example call, the output,
the change log), one parameter per line, the checks first with a message that names the parameter and the fix
(ERROR: [describe] var=AGEX is not a variable of adsl: give var= a variable of data=), %LOCAL for its macro
variables, a begin and an end line in the log. The code is DATA steps and procedures, without PROC SQL and PROC EXPORT,
in the house style of DataStep Studio (datastep format --write sas/macros/*.sas); npm test fails when a macro is not
in it, and node tools/sas_style_check.ts names each place that is not in the standard.
| You call | The procedure in it | It gives |
|---|---|---|
%study | LIBNAME, OPTIONS | the ADaM library, the output folder, the name of the program |
%describe | PROC MEANS | n, mean, SD, median, min, max (Q1, Q3, SE when asked); total=yes |
%freq | PROC FREQ | subjects and percent per category (denom=: the subjects of the percent), records (events); levels=, total=yes; threshold= (the categories of a percent or more in a group), order=frequency (orderof=, orderby=: the rows by their subjects, a twin <V>N) |
%worst | PROC SORT, DATA step | the records of each subject at its worst level of a graded variable (levels= in order) in each row (by=), before %freq (a table by the maximum severity or grade) |
%termflags | PROC SORT, DATA step | one record per subject and term with FLAG Y or N (threshold=), before %risk(within=) (a comparison of each term of an adverse event table) |
%anova | PROC GLM | the F test of a one-way analysis of variance |
%ancova | PROC GLM | the Type III test of a term; its estimate and confidence limits |
%lsmeans | PROC MIXED | LS means and the differences of pairs (pairs=all with order=, or the pairs by value, separated by #) |
%mmrm | PROC MIXED, REPEATED | the mixed model for repeated measures at one visit; the structures of fallback= when one does not converge |
%chisq, %fisher, %cmh | PROC FREQ | chi-square or Fisher's exact test; Fisher's test of subjects with a record against those without; Cochran-Mantel-Haenszel (statistic=rowmean) |
%km, %logrank | PROC LIFETEST | Kaplan-Meier summary (n, events, median and its limits; time=: the survival at a time and its limits; conftype= linear, the stated default, log, loglog); log-rank test, stratified with strata= (several variables: each combination one stratum) |
%proportion | PROC FREQ (BINOMIAL) | subjects and percent per category with the confidence interval of the percent (ci=clopper-pearson, the default; wilson, wald, agresti-coull) |
%risk | PROC FREQ (RISKDIFF, RELRISK, CHISQ; COMMONRISKDIFF, CMH) | the risk difference (ci=), the relative risk or the odds ratio of each pair of groups, the chi-square p-value; with strata= the Mantel-Haenszel risk difference; within= one comparison in each term |
%rateratio | PROC GENMOD | the rate ratio of pairs of groups of a count with its exposure (dist=poisson or negbin, exposure=TRTDUR / 365.25), its Wald limits and p-value |
%rate | PROC FREQ, PROC MEANS | the exposure-adjusted incidence rate of each group (per 100 patient-years) and its exact Poisson limits; count=events: the rate of the events |
%ttest | PROC TTEST | the difference of the means of each pair of groups, its SE, CI and p-value (variances=equal, the default, or unequal) |
%wilcoxon | PROC NPAR1WAY | the Wilcoxon rank-sum test of each pair (normal approximation, correction=yes) and the Hodges-Lehmann shift with its Moses limits |
%adjust | PROC MULTTEST (INPVALUES=), DATA step | the adjusted p-values of a family of pairs of a result (bonferroni, holm, hochberg; weighted bonferroni and the fixed sequence by a DATA step): the rows padj |
%logistic | PROC LOGISTIC | the odds ratio of pairs of groups from one fit (event=, covariates, class factors), its Wald limits and p-value |
%cox | PROC PHREG (and PROC LIFETEST for pvalue=logrank) | the hazard ratio of pairs of groups from one fit, its Wald limits and p-value; strata, covariates, ties=efron (Sapling's default, not PHREG's BRESLOW) |
%stack | DATA step | results one under another: the blocks of a table |
%columns | PROC SUMMARY | the columns of a table in their order (the twin order=), the Total last, the N of each from the population and its header Placebo|(N=86) |
%ard | DATA steps | every number of the results, with every digit, and the N of each column: ard/<output>.csv |
%layout | DATA steps, PROC FORMAT, PROC TRANSPOSE | the display data set: one row per printed line (the block, its levels indented, the statistics of a line in the template of the line: 58.4 (10.40), (1.23, 4.56)), one column per group under its header with N, All groups for a p-value of all groups; labels=: the label of a category (Female); setup=: the call of %print for it in a global macro variable (%print(&t_sex_print.)) |
%print | PROC REPORT | the printer: a listing or the display data set of a table, as <file>.rtf (ODS RTF, style journal) and <file>.lst (LISTING, monospace) |
%table | none of its own | %columns, %ard (with file=), %layout and %print in one call, for a person ("Modify and print again" below); the programs keep the separate calls |
%csv | DATA steps | the records of a listing as CSV: listings/<output>.csv; with folder=figures the numbers of a figure (its out=) |
%kmplot, %forestplot, %meanplot, %lineplot, %boxplot, %barplot, %aeplot, %profileplot, %scatterplot | the procedure of each, then one PROC SGPLOT or SGPANEL | the figures: "Figures" below |
The files that start with _spl_ are the inner parts, small and named for what they do: _spl_begin and _spl_end
(the log lines), _spl_need, _spl_vars and _spl_oneof (the checks of the parameters), _spl_nobs and _spl_empty
(no data qualified: the result without a row), _spl_total (the name of the group Total from total=), _spl_text (the text of a number from its pattern), _spl_rows (the result shape: one row per
statistic), _spl_chars and _spl_levels (the character variables; the planned categories as rows), _spl_pairlist (the pairs of pairs= as rows), _spl_pairs
(the pairs of groups from the differences of PROC MIXED), _spl_stackpairs (the records of each pair of groups one
under the other, for a procedure BY pair), _spl_csv (a data set as CSV, for the test of the figures), and the parts
of the figures: _spl_figure_open and _spl_figure_close (the destinations), _spl_figure_attrs (the look of the
groups), _spl_figure_nodata, _spl_figure_label and _spl_figure_dodge (%layout holds its line table and every
step of the rows itself). None of them computes a statistic.
The printer: %print#
sas/macros/print.sas is one macro of the house standard with one PROC REPORT (data=, by=, var=, together=,
label=, rows=, file=, nodata=, debug=). %layout makes the rows and the columns; the printer only prints
them, into two files at once: <file>.rtf (ODS RTF, style journal: a rule above and below the header and below the
table; landscape) and <file>.lst (the LISTING destination, monospace). ODS wraps and pages the RTF. The BY columns
come first, a repeated value shown once in its run (a run number per BY column, a NOPRINT ORDER column before it, so
that a value that comes back later, such as n in the next block, keeps its place); the last BY column is the ID of a
part in width. The VAR columns follow: numbers right, text left as written ($CHAR, ASIS=ON in the RTF: the indent
of %layout stays); a text column wider than 40 flows. together= keeps the rows of one value on one page of
rows= rows when they fit (a block longer than a page runs on). No row: the text of nodata= under the titles. The
titles and footnotes are the program's own statements. Not in it, on purpose: "(Continued)", page headers, a split in
width by hand (PROC REPORT makes the parts of a wide listing itself), transposition, spanning headers, sorting (the
program sorts).
The line table: one table for SAS and R#
How the numbers of one line of a table stand in their cell is one table, held in one place on each side:
- SAS: the PROC FORMAT of
%layout(sas/macros/layout.sas, Step 3; its header shows the same table): the line and the part of each statistic (_layout_line.,_layout_part.), the label and the template of each line (_layout_label.,_layout_template.):{mean} ({sd}) [{se}],{q1}, {q3},({lcl}, {ucl}),{lsmean} ({lsmean_se}), a category of a frequency block{n} {pct} {events}. - R: the display rows of r-agentic (
standards/ars/display_rows.csv: the row of each operation and its separator,blank,comma,parenorinterval), joined byars_join()(R/ars_format.R) inars_table()(R/ars_output.R); the shells (tools/sap_shells.py) read the same file.
test/layout_table.test.ts reads both and fails when a display row of R and the line of SAS give another template. A
part that is missing leaves out its piece with its brackets or its comma (a lower limit alone prints 1.23, never
(1.23, )). The labels of the lines differ on purpose: R keeps the labels of the CDISC pilot shells (LS Means (SE),
95% CI), SAS the plan's (LS mean (SE), Confidence interval).
Two rules of the texts, the same in R and SAS (sapling sas check compares the texts): a p-value (pval, padj)
never prints as 0 or 1: rounded to 0 by its pattern it is < and the smallest step (X.XXX: <0.001), rounded to 1
>0.999 (%_spl_text with pvalue=, R ars_format(pvalue = TRUE)); its number is unchanged. A category the plan
labels (a codelist of its ADaM specification whose decode is not its value: F = Female) prints its label: the
program writes each codelist as a format (PROC FORMAT, VALUE $sexf 'F' = 'Female': the name of the variable and
f) and gives variable and format to format= of %layout, as in a FORMAT statement (format = sex $sexf.); R
decodes the same codelist (ars_table()). A table program has no DATALINES. The order of the rows stays that of the values.
How each table prints through it: %layout makes the display data set (GRP, ROW with its indent in $CHARw.,
C1 ... Cn labelled Placebo|(N=86), C99 All groups), and the call is the same for every table: by=row,
var=c:, together=grp, label=row=" ", file=&spl_out./tlf/<output>; %layout keeps it in the macro variable of
setup= and the program prints with %print(&<output>_print.). So:
- a demographics table: a block per characteristic with
n,Mean (SD),Median,Min, Max, or a category per line withn (pct), the count right aligned, a blank line between the blocks; - comparisons of pairs: under the block,
vs <group>indented, the estimate (Hazard ratio, Odds ratio, Rate ratio, Risk difference, Difference (SE), LS mean (SE)), its confidence interval and p-value in the column of the group; a p-value of all groups in the columnAll groups; - a Total: the last column (
total=of%columns); - rows by a second variable (a parameter, a visit): its levels indented under the block;
together=grpkeeps each level of the first one on a page when it fits; - incidence by SOC and PT: the PT indented under its SOC,
n (pct) events; - a subgroup table: the levels of the factor indented under each pair, the test of the interaction as its own block;
- a listing:
%print(data=l14_5_1, by=trt01a usubjid, var=..., file=&spl_out./listings/l14_5_1), its records sorted in the program and written as CSV by%csvfirst.
Its tests on SAS. node tools/print_sas.ts prints made-up data on the SAS server: a table from %layout (two
groups, a Total, All groups), together= (a block that would be cut moves whole to the next page; a longer block runs
on), no together=, no row, a listing (a repeated subject shown once, a long text that flows, a number right
aligned), and three wrong calls (one ERROR: [print] with its fix, no file); then the setup of %layout and %table
("Modify and print again" below): %print(&name.) prints the same file as the call written out; a second print after
a row label is changed and a row deleted differs in exactly those rows; a label with a comma, quotes, & and %
arrives whole; without setup= no global macro variable is left (sashelp.vmacro before and after); a name that is
not a SAS name, or a name of the macro, gives one ERROR: [layout]; %table writes the same .lst and numbers file
as the separate calls, with and without setup=; and the owner's example (the results of %freq changed, %table
without a print, the display data set changed, the print from the setup): PRINT SAS PASS: 18 cases. The files come
back to .scratch/print_sas/out/ and are read as text.
Modify and print again#
Each macro leaves a data set the programmer can change before the next step: the results of a statistics macro
(out=, the long result shape), the display data set of %layout (one row per printed line). setup= of %layout
(blank by default: then no macro variable is made) keeps the call of %print for the display data set in one global
macro variable of that name, so that the table is printed again after a change with the same parameters. The rule of
the setup:
- It holds
data=(the display data set),by=row,var=c:,together=grpandlabel=(row=" "and the labels given tolabel=of%layout), androws=,file=andnodata=when they were given to%layout. The NOTE[layout] setup=<name> keeps the print of ...in the log shows it. %print(&<name>.)prints from it. A parameter that is not in the setup is added after it:%print(&t_sex_print., file=&spl_out./tlf/t_sex). A parameter that is in it cannot be given again: SAS stops a call that gives a keyword twice (ERROR: The keyword parameter FILE passed to macro PRINT was given a value twice.). To change one, give it to%layout(or%table) again, or write the%printcall out.- It is stored with its macro quoting: a label with a comma, a quote,
&or%(given aslabel=%NRSTR(c1="Drug, 1 & 2 (100%%)")) arrives in%printas the same text, and the commas between the parameters still separate them.
%table makes a table in one call: %columns, %ard (only with file=: the numbers file belongs to an output),
%layout and %print, with the same files as the separate calls (node tools/print_sas.ts compares them byte for
byte). Without file= it prints nothing: change the display data set, then print with file=.
%table(data=, n=, var=, order=, total=no, format=, label=, rows=40, file=, nodata=, setup=, out=, debug=no)
The one-call tables. Seven macros make a whole table from one call. Each writes the long program of its table (the
population, the records, one call per block, %stack, %table) and runs it; none computes anything. The results, the
table <name>, <name>_cols, <name>_display and the setup <name>_print stay for a change and a second %print;
show=yes puts the long program in the log, show=<file> writes it to a file to go on from by hand. The programs
Sapling writes keep the long form. With adsl= and pop= the population comes from the subjects' data set and its
variables go to the records; where= chooses the records.
| Macro | Table | Blocks |
|---|---|---|
%table_summary | demographics, baseline: vars= with # after a categorical variable | %describe, %freq |
%table_ae | adverse events by the levels of var= (sort=frequency, threshold=) | %freq |
%table_visit | each variable of vars= by parameter and visit (post=chg: after baseline only) | %describe |
%table_response | responders: percent with interval, measure=rd/rr/or, with covars= the adjusted odds ratio | %proportion, %risk, %logistic |
%table_model | descriptive blocks (describe=) at the visit at=, then method=ancova or mmrm | %describe, %lsmeans, %mmrm |
%table_tte | Kaplan-Meier, log-rank test, hazard ratio | %km, %logrank, %cox |
%table_shift | the category at baseline against the category after it (var= two variables, levels=) | %freq |
Their proof: node tools/tables_sas.ts (TABLES SAS PASS: the display data set of each one call equal to that of the
long program written by hand, on SYNHTN01) and, for %table_summary, node tools/print_sas.ts.
A summary table in one call: %table_summary. You name the variables; # after a name marks a categorical
variable (sex#), and a numeric variable after the # gives the order of its categories (agegr1#agegr1n). A
character variable without # is an error with its fix. The macro computes nothing: it writes the long program (the
population, one %describe or %freq per variable, %stack, %table) and runs it, so the results
<name>_<variable>_summ, the table <name>, <name>_cols, <name>_display and the setup <name>_print stay for a
change and a second %print. show=yes puts the long program in the log; show=<file> writes it to a file to go on
from by hand. The programs Sapling writes keep the long form. Its proof: node tools/print_sas.ts (the same .lst and
the same numbers as the long program).
%table_summary(
data = adsl
, where = (saffl = 'Y')
, vars = age agegr1#agegr1n sex# race# ethnic#
, by = trt01a
, order = trt01an
, total = yes
, format = sex $sexf. race $racef.
, file = &spl_out./tlf/t14_2_1
)
It leaves <data>_cols (the columns) and out= (the display data set, default <data>_display). The owner's example,
a frequency table changed twice (one of the cases of node tools/print_sas.ts):
%freq(data=adsl, var=sex, by=trt01a, total=yes, label="Sex", out=sex)
/* change the results: the label of the block; then the table of one block (%stack names its result) */
DATA sex;
SET sex;
block = 'Sex at birth';
RUN;
%stack(sex, out=t_sex)
/* the columns, the display data set t_sex_display and the setup t_sex_print; no print yet */
%table(data=t_sex, n=adsl, var=trt01a, order=trt01an, total=yes, setup=t_sex_print)
/* change the display data set: the label of a row */
DATA t_sex_display;
SET t_sex_display;
IF row = ' F' THEN row = ' Female';
RUN;
%print(&t_sex_print., file=&spl_out./tlf/t_sex)
The programs Sapling writes keep the separate calls (%columns, %ard, %layout with setup=<output>_print and
file=, the titles, %print(&<output>_print.)), so that each shows the place to change a table and print it again.
A new method is one new macro file that holds its procedure: it reads data= and where=, runs the procedure
(ODS OUTPUT for its tables), and writes out= in the result shape with %_spl_rows(stats=...). Then the engine of the
method in src/sas.ts (SAS_ENGINES, SAS_STATS, and its case in sasPrograms) writes its call.
To look inside while a program runs, use DataStep Studio: datastep pry "vt work.t2_age_summ" shows a result;
datastep trace sas/macros/freq.sas --call '%freq(data=adsl, var=SEX, by=TRT01A, out=sex)' shows each statement a macro
ran; debug=yes keeps the work data sets of a macro.
Each macro on its known answer#
The method registry (src/methods.ts) gives each method a small known answer (12 made-up subjects, the numbers worked
out by tools/method_answers.R) and the SAS calls of its macro on it. node tools/methods_sas.ts copies the macro
library to ~/sapling/fixtures/macros on the SAS server of DataStep Studio, writes the fixture study as DATA steps (no
data of a study), runs the calls of each method (each run with a time limit; one that does not come back is stopped)
and compares every row of the result with the known answer: METHODS SAS PASS / FAIL. A method whose macro passed
has sas.run in the registry, and only then can sapling sas check say PASS for an analysis it makes.
Proven on SAS (2026-11-12, SAS 9.4 M9)#
On 2026-11-12 the rewritten library ran for the first time on a SAS server: SAS 9.4 M9 (9.04.01M9P06052025) through DataStep Studio 0.2.35, only dummy and made-up data.
| What | Verdict |
|---|---|
| every method of the registry on its known answer | METHODS SAS PASS: 29 method(s), 45 call(s), 360 numbers equal to the known answers |
| every step kind of the ADaM on its fixtures | ADAM STEPS SAS PASS: 32 step kinds, 36 fixtures, 210 records equal to the expected records (again with the new %tte: one call per parameter, the at-risk period) |
the dummy study SYNHTN01 (sapling dummy): 9 tables, 2 listings, 2 figures | SAS RUN PASS, flat programs 9 of 9 proved; SAS CHECK PASS: 570 of 570 numbers of R are the same in SAS; listings and figures: 4 of 4 |
| its ADaM data sets made in SAS from SDTM | SAS ADAM RUN PASS: 3 of 3 data set(s) with the same values as R |
| the old library against the new one (a table of every method of the old library, a Kaplan-Meier figure) | 531 of 531 and 21 of 21 numbers the same |
So every macro of the methods and of the step kinds has its run (sas.run, sasRun), no header says "not yet run on
a SAS server", and SAS_ADAM_NOT_RUN_MACROS is empty. A new macro, or a new part of one, is again "not yet run" (in
the Proven of its header and in that list) until it has passed there.
What the run found and fixed: macro quoting that reached the DATA step (%_spl_levels, %_spl_rows); a hyphen or the
word or in %IF (ci=miettinen-nurminen, measure=or, pairs "A"-"B"); the estimate of PROC FREQ's PdiffCLs
(RiskDifference); the length of the group of the LS means; the group of %fisher from DENOM; a note on missing
values for max(), min() and the dates of days(). And one number of R: the Mantel-Haenszel risk difference used
Sato's variance with two terms swapped (R engine and known answer both); SAS's COMMONRISKDIFF(CL=MH) was right, and both
engines now keep the limits of a risk difference within -1 and 1, as SAS does.
Two conventions are now the same on both sides:
- Kenward-Roger (
df="kenward-roger"of an MMRM) is SAS PROC MIXEDDDFM=KR. WithTYPE=UNon the fixture SAS gives the textbook standard errors (0.645 for an LS mean), and so doDDFM=KR(LINEAR)andKR2; R's mmrm gives them withvcov = "Kenward-Roger-Linear", its own default "Kenward-Roger" (a Cholesky parameterization) gives 0.607. The R engine uses the linear one; on the dummy study the two agree for UN and CS as well. The MMRM fixture isdf="kenward-roger". - The test of an interaction is the same test in both: the F test of the treatment-by-factor terms of a linear
model (R:
anova()of the two nested fits; SAS: the Type III F test of PROC GLM), and for a logistic, Poisson, negative binomial or Cox model the likelihood-ratio test (R: twice the difference of the log likelihoods of the two fits; SAS: the difference of the likelihood-ratio chi-squares of PROC LOGISTIC and PHREG, twice the difference of the full log likelihoods of PROC GENMOD), with the difference of the numbers of coefficients as its degrees of freedom.
The library 1.1.0 on SAS (2026-11-12, SAS 9.4 M9)#
The house standard of sas-style.md (68 macros, library 1.1.0: the headers, the signatures, the checks
first, the begin and end lines, debug=, the pairs separated by #, the values written into the code by
%_spl_quote) ran on the same server:
| What | Verdict |
|---|---|
every method of the registry on its known answer (now with %worst: catsumm with worst=) | METHODS SAS PASS: 31 method(s), 50 call(s), 425 numbers equal to the known answers |
every step kind of the ADaM on its fixtures (%tte in the standard) | ADAM STEPS SAS PASS: 33 step kinds, 37 fixtures, 217 records equal to the expected records |
| a copy of SYNHTN01 on its dummy data with made-up test blocks (a time to event in an at-risk period, worst=, threshold=, order="frequency", the risk difference of each term, the rate of the events, a subgroup block, a Cox model with two pairs): 12 tables, 3 listings, 3 figures | SAS RUN PASS: 12 program(s) ...; flat programs (no macro, out/sas/flat): 12 of 12 proved by their own run |
| the same, R against SAS | SAS CHECK FAIL: 739 of 759 numbers of R are the same in SAS, 20 differ: the 20 are the hazard ratios, limits and p-values of PROC PHREG (Efron ties), 1E-7 to 9E-5 relative from R (below) |
What the run found and fixed: %worst compared each record with a level that %QSCAN left macro-quoted (the step
failed: SAS read the text in quotes as a symbol of its own); %termflags dropped a variable it makes only without
terms (a WARNING in every per-term comparison); the macros read label=, pairs= and the titles with SYMGET when the
DATA step ran, so the flat programs had no labels and no pairs (every comparison failed there): %_spl_quote writes the
value into the code, and the checker has the rule flat. The generator of the ADaM programs gives a computed value of
a condition (TRTEDT + 30, days(...) > 91) its own guarded statement before the condition: SAS computes the
arithmetic of a condition before AND and OR decide, and wrote a note on missing values (the CHECK of the fixtures
flag.teae and tte.ordered).
Not proven on SAS:
- the Cox model on the dummy data: R's estimate is converged (survival::coxph with eps 1E-9 and 1E-14 agree to 1E-10: hazard ratio 1.966161 of Drug X against Placebo); SAS PROC PHREG with its default convergence gives 1.966123. The known answer of the method cox (12 subjects) is the same in both to 1E-6. Whether PHREG reaches R's value with a tighter GCONV= was not run (the session was blocked); the Cox numbers are not yet compared as iterative ones;
- the hooks of
custom/<ds>.sason a study (the derivation fixturecustomran; the CDISC pilot ran on 2026-11-13, below); - the result without a row ("No data qualified"), the empty table (
%layoutwithout a row:%printwrites itsnodata=text) are in no fixture yet (the empty figure of each figure macro is a case oftools/figures_sas.ts); - an MMRM with a structure other than UN or CS: on the dummy data the fits of AR(1) and TOEP of SAS and of R's mmrm differ in the fourth digit of the estimate (not explained yet);
- the intervals of a risk difference other than Newcombe's and Mantel-Haenszel's (Wald, Miettinen-Nurminen, Agresti-Caffo) are in no fixture: on the fixture SAS and R gave the same limits (to 1E-8) when run by hand;
- the one-sided p-value of a one-sided test and
minimum=of a subgroup block are R's only (the SAS program says so above the call).
The library 1.2.0 on SAS: the owner's printer (2026-11-12, SAS 9.4 M9)#
The owner's %PRINT as the printer, %columns, %ard, %layout and %csv as the steps of a program before it:
| What | Verdict |
|---|---|
his tests of %PRINT | PRINT SAS PASS: 24 functional, 6 error cases |
| every method of the registry on its known answer | METHODS SAS PASS: 31 method(s), 50 call(s), 425 numbers equal to the known answers |
| every step kind of the ADaM on its fixtures | ADAM STEPS SAS PASS: 33 step kinds, 37 fixtures, 217 records equal to the expected records |
| the copy of SYNHTN01 with its made-up test blocks: 12 tables, 3 listings, 3 figures | SAS RUN PASS: 12 program(s) ...; flat programs (no macro, out/sas/flat): 12 of 12 proved by their own run |
| the same, R against SAS | SAS CHECK PASS: 759 of 759 numbers of R are the same in SAS; listings and figures: 6 of 6 the same in R and SAS |
| its ADaM data sets made in SAS from SDTM | SAS ADAM RUN PASS: 4 program(s) ...: 4 of 4 data set(s) with the same values as R |
The short printer on SAS (2026-11-12, SAS 9.4 M9)#
%print rewritten short (one PROC REPORT into RTF and LISTING), the copy of the owner's %PRINT out of Sapling:
| What | Verdict |
|---|---|
the cases of %print | PRINT SAS PASS: 8 cases |
| every method of the registry on its known answer | METHODS SAS PASS: 31 method(s), 50 call(s), 425 numbers equal to the known answers |
| every step kind of the ADaM on its fixtures | ADAM STEPS SAS PASS: 33 step kinds, 37 fixtures, 217 records equal to the expected records |
| the copy of SYNHTN01: 12 tables, 3 listings, 3 figures | SAS RUN PASS: 12 program(s) ...: 12 table(s), 3 listing(s) (15 RTF), 3 figure(s) ...; flat programs (no macro, out/sas/flat): 12 of 12 proved by their own run |
| the same, R against SAS | SAS CHECK PASS: 759 of 759 numbers of R are the same in SAS; listings and figures: 6 of 6 the same in R and SAS |
The rows and cells of every .lst are those of the owner's printer, but: no "(Continued)"; a listing without
together= is one page per part in width (the LISTING page is PS=32767 of %study; the RTF pages itself), so its
treatment is no more repeated at the top of each page; a row label longer than 60 stays on one line (it was wrapped); a
header without | wraps at its blanks (All groups on two lines); a cell that was cut (50 (100.0% in T14_1_1) is
whole.
The CDISC pilot on SAS (2026-11-13, SAS 9.4 M9, library 1.5.0)#
The first study with real data on SAS: the CDISC pilot CDISCPILOT01 (public CDISC example data; the owner said yes to
copying it to his SAS OnDemand session, --upload-data). The study folder as zsh tools/pilot_qmd.sh makes it (sapling new to the dashboard's gate in R: PILOT QMD PASS), then in a copy of it, SAS 9.4 M9 (9.04.01M9P060525, Linux) through
DataStep Studio 0.2.35:
| What | Verdict |
|---|---|
| the 7 ADaM data sets made in SAS from SDTM | SAS ADAM RUN PASS: 7 program(s) ...: 7 of 7 data set(s) with the same values as R (ADSL 254, ADAE 1191, ADQSADAS 983, ADQSCIBI 730, ADVS 29643, ADLBC 32740, ADLBH 21919 rows; PROC COMPARE code 4: the informat DATE9. that R's transport file gives a date, no value differs) |
| the 12 tables, 3 listings, 3 figures | SAS RUN PASS: 12 program(s) ...: 12 table(s), 3 listing(s) (15 RTF), 11 figure(s) ...; flat programs (no macro, out/sas/flat): 12 of 12 proved by their own run, every log clean |
| R against SAS | SAS CHECK PASS: 18392 of 18392 numbers of R are the same in SAS; listings and figures: 6 of 6 the same in R and SAS |
What the run found and fixed:
- The categories of a shift table (T14_6_2): 108 numbers of R missing in SAS, nine zero cells per arm for MICROCY
and POIKILO, whose records have no normal range (BNRIND blank, ANRIND
ABNORMAL). R planned every parameter with a record of the subset;%freqdropped a record with a blank BNRIND first, but kept ANISO, MACROCY and POLYCHR (records outside the levels) with zeros. The CSR's table 14-6.02 lists the 12 hematology parameters with a range and none of these. Now both take the categories of a variable withoutlevels=from the records counted (a planned category of each listed variable; the treatments and the pairs of a comparison do not choose them):%freqStep 3, r-agenticars_plan(). R's results of T14_6_2: 270 zero cells fewer (18662 to 18392), the digest of the pilot written again. - A cut text:
%stackset ADLBC's results (PARAM 40 long) before ADLBH's: 'Ery. Mean Corpuscular Hemoglobin (fmol(Fe))' printed as... (fmol(F, with a WARNING in T14_6_1 and T14_6_2. Each variable now has the longest length of the results. - The p-value of each term (T14_5_2, Fisher's exact test of each preferred term): a block of its own that repeated every SOC and term (966 lines); now in the column All groups of the term's row, as R and the CSR print it (333 lines).
- A long title was written in quoted pieces that SAS joins without a blank (
andPreferred Term).
Read as a statistician: T14_3_1 (ADAS-Cog (11), change from baseline to Week 24, LOCF) has the CSR's numbers of table 14-3.01: baseline 24.1 (12.19), 24.4 (12.92), 21.3 (11.74); change 2.5 (5.80), 2.0 (5.55), 1.5 (4.26); the p-value of the dose response 0.245. T14_2_1 has the CSR's p-values of age (0.593, ANOVA) and sex (0.141, chi-square); its age groups and race are the plan's categories, not the CSR's.
Not proven by this run:
- the pairwise comparisons of T14_3_1 (Xanomeline against Placebo, high against low: LS means, 95% CI) are not in the plan, so neither engine made them; the CSR's 0.569, 0.233 and 0.520 are not compared;
- the safety tables group by TRT01A = ACTARM of the SDTM (N 86, 96, 72); the CSR by the planned arm (86, 84, 84): their counts are not the CSR's, and only R and SAS agree on them;
- the figures are compared by their numbers (CSV), not as pictures; F14_6_1 is nine PNG pages in SAS, one file in R.
The link to the plan#
A program names no id of the plan. Sapling writes it from the contract and keeps the link of its numbers to the plan
outside SAS, in out/sas/programs.json (link of each output):
- each result is the analysis of the same name:
out=t2_age_summis the analysisT2_AGE_SUMM(an id that is no SAS name, longer than 32 characters, getsr1,r2..., named in the header of the program); - the N of the columns (
%columns, written by%ardas resultN) is the count of subjects of the plan by the columns (T14_2_01_N); - each listed group of the contract is the value SAS shows for it: the value of its condition
(
TRT_TOT_1isPlacebo), or the name of its total (TRT_TOT_4isTotal).
%ard writes every number to ard/<output>.csv (result, statistic, the values of its groups, the number, its text);
sapling sas check reads R's numbers by analysis, operation and groups, and SAS's through the link. This is cleaner than
an id on each call or a mapping line at the end of a program: the program says what is computed, and the name of each
result already says which analysis it is.
A method of the plan without a macro here, or groups that are conditions and not the values of one variable, are
NOT MADE: the program says so in a comment and in its header, and sapling sas check then ends OPEN, not PASS.
A mixed model is fitted by iteration, so SAS and R agree to about five digits, not to the last one: sapling sas check
takes its numbers as the same when they differ by less than 1E-4 (relative), and says so in its report.
Listings and figures#
The SAP plans tables. A listing or figure here goes with a table and shows the same records or numbers another way:
- a table of events by groups (adverse events by class and term): the listing of the records it counts (a DATA step
that selects them, a PROC SORT,
%csv, then%print(var=..., by=<group> usubjid)); - a continuous summary by visit: the figure of the mean and its standard error by visit (
%meanplotof the same records, the visit by its numeric twinAVISITN, its labels a format made from the records); - a table with a model test: the box plot of the tested variable by treatment (
%boxplotof the records); - a table of subgroups: the forest plot of the results of its levels (
%forestplotof the same results).
Their ids are the table's with L or F. They are not outputs of the SAP, and their titles say which table they go
with. --no-companions leaves them out. A figure of the plan (a Kaplan-Meier curve) prints its numbers as a table and
ends with %kmplot (the number at risk under the axis, the medians). Each figure writes its out= to
figures/<id>.csv with %csv(folder=figures).
R makes the same listings and figures: sapling tlf companions runs r/spl_companions.R (base R, haven, ggplot2),
whose figures are the R twins of the macros (r/spl_figures.R), on the ADaM data and the results of the tables, and
writes out/tlf/companions/listings/*.csv and figures/*.png with the numbers of each figure as CSV. sapling sas check then compares every record of a listing and every number of a figure (every column of out=), R against SAS.
Figures#
One macro per figure, named as the figure. A user calls it on ADaM records and gets the figure; the macro computes what
it draws with its procedure (shown in its code), then draws it with exactly one PROC SGPLOT or SGPANEL. %forestplot
alone draws results (of %cox, %logistic, %rateratio, %lsmeans, %risk with within=), so that the figure and
the table of the same results cannot differ. %lineplot draws values given (LS means by visit) and computes nothing.
Each writes the numbers it draws to out= (one row per point, bar or box: the group variables, KIND, X, Y,
LOWER, UPPER, N, and what the figure has more: the quartiles of a box, the slope of a line), and the figure to
file= (no extension): <file>.png at 300 dpi and <file>.rtf with the figure on a page. The titles and footnotes
are the program's own TITLE and FOOTNOTE statements: in the RTF they are text of the page (NOGTITLE NOGFOOTNOTE); the
PNG carries them in the image (LISTING draws them in the graph, as SAS does), so that a PNG alone says what it is.
The common parameters, in the same order in each (the order of the house standard: the data sets, where=, the
variables, the options, the presentation, out=): data= (or plot= and par=: below), columns= (the data set
of %columns: the order of the groups and their place in the colour list, the same in every figure of a study),
where=, the variables of the figure, by= (one colour, marker and line pattern per value), panel= (one panel per
value; in %meanplot, %lineplot, %boxplot and %profileplot one under the other, each with its own y axis), the
options, xlabel=, ylabel=, ref= (reference lines), file=, width=6.5in, height=4.5in, out=, outpar=,
debug=.
The look is one: _spl_figure_attrs gives each group one of eight colours safe for colour blindness (Okabe and Ito
without the yellow, and a grey: #0072B2 #D55E00 #009E73 #CC79A7 #E69F00 #56B4E9 #000000 #777777), each with its own
marker (filled circle, triangle, square, diamond, then the open ones) and line pattern (solid, short dash, long dash
...), in the order of columns=, else of the data; _spl_figure_open and _spl_figure_close own ODS GRAPHICS (image
name, PNG, size, reset), the RTF file and the LISTING destination, and set back what they changed; the style HTMLBLUE:
tick values 9pt, axis labels and legend 10pt, the tables under an axis and the texts in a plot 8pt. No record after
where=: the figure that says "No data qualified", out= with no row and a NOTE.
%kmplot(data=adtte, columns=t14_1_cols, where=(PARAMCD = 'TTDE'), time=AVAL, cnsr=CNSR, by=TRTA, median=yes,
xlabel="Days", file=&spl_out./figures/f14_1, out=f14_1)
%forestplot(data=t14_2_sg, estimate=hr, within=SEX AGEGR1 RACE, by=TRTA, scale=log, ref=1,
file=&spl_out./figures/f14_2_sg, out=f14_2_sg)
%meanplot(data=advs, columns=t14_7_cols, where=(ANL01FL = 'Y'), y=CHG, x=AVISITN, by=TRTA, panel=PARAM, ntable=yes,
xformat=VISIT., ref=0, file=&spl_out./figures/f14_7_1, out=f14_7_1)
%lineplot(data=lsm_chg, x=AVISITN, y=ESTIMATE, by=TRTP, lower=LCL, upper=UCL, xformat=VISIT., ref=0,
file=&spl_out./figures/f14_3_2, out=f14_3_2)
%boxplot(data=advs, where=(PARAMCD = 'SYSBP'), y=CHG, x=AVISITN, by=TRTA, xformat=VISIT.,
file=&spl_out./figures/f14_3_1, out=f14_3_1)
%barplot(data=adae, denom=adsl, where=(TRTEMFL = 'Y'), var=AESEV, by=TRTA, file=&spl_out./figures/f14_5_2, out=f14_5_2)
%aeplot(data=adae, denom=adsl, where=(TRTEMFL = 'Y'), term=AEDECOD, by=TRTA, reference="Placebo", threshold=5,
file=&spl_out./figures/f14_5_3, out=f14_5_3)
%profileplot(data=adlbc, where=(PARAMCD = 'ALT'), x=ADY, y=AVAL, by=TRTA, ref=40, file=&spl_out./figures/f14_6_1,
out=f14_6_1)
%scatterplot(data=adlbc, where=(PARAMCD = 'ALT' and AVISITN = 24), x=BASE, y=AVAL, by=TRTA, identity=yes,
file=&spl_out./figures/f14_6_2, out=f14_6_2)
| Macro | Computes with | Draws | out= |
|---|---|---|---|
%kmplot | PROC LIFETEST (OUTSURV=, the quartiles), PROC SUMMARY | the survival in percent as steps, a mark at each censoring, the number at risk under the axis at its ticks (times=, default about 6 round ticks), with median=yes the median (CI, conftype=loglog) of each group | KIND curve (X time, Y survival %, CENSORED), atrisk (X tick, N), median (X, LOWER, UPPER) |
%forestplot | (results) | a row per level under the heading of its factor, the estimate and its interval, a line at ref=, a log axis for scale=log, the estimate (lower, upper) as text | FACTOR, LEVEL, PAIR, X, LOWER, UPPER, N |
%meanplot | PROC MEANS | the mean (or median) by visit with SE, SD, the CI of the mean, or Q1 and Q3, the groups a little apart, ntable=yes the n under the axis | X visit, Y, LOWER, UPPER, N |
%lineplot | nothing | lines through the values given, the interval as bars or a band | X, Y, LOWER, UPPER |
%boxplot | PROC MEANS (QNTLDEF=5) | boxes Q1 to Q3 with the median, whiskers to the most extreme value within 1.5 IQR, outliers beyond, the mean as a marker | N, MEAN, MEDIAN, Q1, Q3, MIN, MAX, WLOW, WHIGH |
%barplot | PROC FREQ | the percent of the subjects (denom=) or records, or the count, grouped or stacked, the value on each bar | X category, Y, N, DENOM |
%aeplot | %termflags, %risk, PROC SUMMARY | two panels on the terms: the percent of each group, the risk difference against reference= with its interval | KIND percent (X, N), difference (X, LOWER, UPPER: 100 x RD of %risk) |
%profileplot | PROC MEANS | one thin line per subject in the colour of its group, mean=yes the group means as thick lines | KIND subject (X, Y), mean (X, Y, N) |
%scatterplot | PROC REG | the points by group, fit=linear the line of each group, fit=loess SGPLOT's loess, identity=yes y = x | KIND point (X, Y), fit (SLOPE, INTERCEPT, RSQUARE, N) |
Each macro is proven by node tools/figures_sas.ts on made-up data on SAS: its log clean, its out= equal to a known
answer written by hand in the tool, its PNG and RTF made, the empty case, one wrong call with its one message; and the
same data through its R twin of r/spl_figures.R (spl_kmplot, spl_forestplot, ...: ggplot2, the same out
numbers): FIGURES SAS PASS: 9 macros, 103 cases (31 of them the figures themselves, 72 the drawing again below),
FIGURES R PASS: 9 macros, 10 cases. The quantiles of the boxes are R's type 2 (QNTLDEF=5); the median interval of
%kmplot is survfit's (the interval of the curve where it crosses 50%, the same as PROC LIFETEST's on the known
answer). fit=loess is drawn by SGPLOT and ggplot2 each with its own smoothing: its curve is not in out= and not
compared.
Modify and draw again#
A figure macro leaves two data sets the programmer can change: out=, the numbers it draws, and outpar= (blank by
default: then no data set is made), its drawing parameters, one row each in the order of the signature: PARAMETER
($32, the name in lower case) and VALUE ($500, the value as the macro draws it: given, from par=, the default, or
made by the macro, such as the label of an axis from the label of its variable, in quotes, or the format of the ticks).
Two parameters draw them again without computing:
plot=a data set in the shape of the macro'sout=(its header says the shape): the macro skips its statistics and draws it.data=is not given then (both: one error). A parameter of the computation given withplot=(where=, the analysis variables,stat=,error=,conftype=,fit=...) is not used, and a NOTE names it.out=andoutpar=may be given again: the numbers drawn, after the macro's own reading.par=a data set in the shape ofoutpar=: each row sets its drawing parameter. A row that names no drawing parameter of the macro is one error that names it; aplot=without a variable ofout=is one error with the shape.
A drawing parameter written in the call wins over its row of par=, and the row over the default. So that the macro can
tell a value written in the call from its default, the defaults of these parameters are in the macro, not in its
signature: the signature leaves them blank, %_spl_figure_par (one inner part, the same call in all nine) reads par=
into the blank ones and then gives each one still blank the default of its list (draw= and compute=, as
name=default), and the header shows that default; %_spl_figure_outpar writes outpar=. The drawing parameters are
those that change the picture but not the numbers; each header names them in its Note:
| Macro | Drawing parameters (outpar=) | Drawn from plot= |
|---|---|---|
%kmplot | columns by panel atrisk median level xlabel ylabel ref file width height | the ticks are the X of the rows of KIND atrisk; level= only writes the heading of the medians |
%forestplot | columns estimate scale text xlabel ref file width height | the order of the rows; each factor under its name (the label of its variable is not in out=); estimate= only names the text column |
%meanplot | columns by panel connect ntable xformat xlabel ylabel ref file width height | error bars where LOWER has a value |
%lineplot | columns by panel band xformat xlabel ylabel ref file width height | an interval where LOWER has a value |
%boxplot | columns by panel outliers mean xformat xlabel ylabel ref file width height | each box from its statistics (whiskers WLOW and WHIGH, Q1 to Q3, the median, the mean); as outliers only MIN and MAX beyond a whisker |
%barplot | columns by panel stacked labels xlabel ylabel ref file width height | the categories in the order of their first row; one decimal on a bar unless every Y is its N |
%aeplot | columns term by xlabel file width height | the terms in the order of their first row (sort= and max= choose the rows of out=: they compute) |
%profileplot | columns subject by panel mean xformat xlabel ylabel ref file width height | mean=no leaves out the rows of KIND mean |
%scatterplot | columns by panel identity xlabel ylabel ref file width height | a line for each row of KIND fit; a loess curve is not in out= and not drawn again |
The owner's example, as it runs on SAS (the ADTTE of the dummy study SYNHTN01, whose parameter is TTAE and whose times
end at 89 days; on the CDISC pilot paramcd = 'TTDE' and x > 180):
%kmplot(data=adtte, where=(paramcd = 'TTAE'), time=aval, by=trta, out=km, outpar=km_par, file=&spl_out./figures/f_km)
DATA km; SET km; IF kind = 'curve' AND x > 60 THEN DELETE; RUN;
DATA km_par; SET km_par; IF parameter = 'xlabel' THEN value = '"Days since first dose"'; RUN;
%kmplot(plot=km, par=km_par)
The first call writes km_par with the rows columns, by (TRTA), panel, atrisk (yes), median (no), level
(0.95), xlabel ("Analysis Value", the label of AVAL), ylabel ("Survival (%)"), ref, file, width (6.5in) and
height (4.5in). The second draws the curves to day 60 under the label "Days since first dose" into the same file
(file of km_par); the number at risk keeps its ticks to day 80, the X of the rows of KIND atrisk, which the change
kept. Both logs are clean. To keep the first figure, give the second call its own file=: the call wins over the row.
node tools/figures_sas.ts proves it for each macro on its case with a known answer: (a) without outpar= the call
makes no data set but out=, and outpar= has exactly the rows of the table above; (b) plot= the untouched out=
and par= the untouched outpar= give the same out= and a PNG; (c) one row of out= and the xlabel row changed:
the new out= differs in that row only, and tesseract reads the new label in the PNG (the label is drawn in the image;
the RTF holds the image, not the label as text); (d) xlabel= and file= of the call win over their rows, and
where= given with plot= gets its NOTE; (e) a row colour and a plot= without one variable of out=: one
ERROR: [macro] each; (f) plot= with data=: one ERROR: [macro]. The R twins (r/spl_figures.R) do not take
plot= and par=: each returns its ggplot object with out, and a ggplot object is changed and saved again in R itself.
The ADaM data sets in SAS#
sapling sas adam programs # out/sas/adam/programs/<data set>.sas, in the build order (no SAS needed)
sapling sas adam run # on the SAS server: each data set made, then compared with the data set of R
| Command | Verdict |
|---|---|
sapling sas adam programs | SAS ADAM PROGRAMS PASS: n program(s) ..., with each step NOT MADE in SAS and each part not yet run on a SAS server |
sapling sas adam run [--upload-data] | SAS ADAM RUN PASS / OPEN / FAIL: n of m data set(s) with the same values as R |
An ADaM program has two kinds of step, each under a comment with the block of the SAP it comes from and the sentence of the protocol (as in the R program):
- A derivation of one record is a plain DATA step: the rows of the source, an assignment,
IF ... ELSE IFfor the cases,SELECTfor the parameters. You read the rule where it is. - A step that reads other records is a macro with a plain name that holds its own sort, merge or procedure. It keeps
the rows of the data set in their order (it numbers them on entry and sorts them back), deletes its work data sets
(
_pick_value), and leaves nothing behind for the next step.
data work.adsl;
set sdtm.dm;
if ARMCD not in ('SCRNFAIL');
run;
/* SAP 7.1.1: ::: {.derive variable="ADSL.TRTSDT" rule="date(EXSTDTC)" from="EX" first="date(EXSTDTC); EXSEQ"} */
data work.ex;
set sdtm.ex;
if length(strip(EXSTDTC)) >= 10 then _value = input(substr(EXSTDTC, 1, 10), ?? yymmdd10.);
_key1 = input(substr(EXSTDTC, 1, 10), ?? yymmdd10.);
run;
%pick(data=work.adsl, from=work.ex, new=TRTSDT, value=_value, order=_key1 EXSEQ, format=date9.)
%windows(
data = work.advs
, windows = 0 "Baseline" . 1 1 "<=1"
# 4 "Week 4" 2 42 28 "2-42"
# 8 "Week 8" 43 70 56 "43-70"
# 12 "Week 12" 71 . 84 ">70"
)
%flag_first(data=work.advs, new=ANL01FL, where=(not missing(AVISITN) and not missing(AVAL)),
by=USUBJID PARAMCD AVISITN, order=AWTDIFF ADY VSSEQ)
%baseline(data=work.advs, new=BASE, value=AVAL, by=USUBJID PARAMCD)
| Macro | It does | Step of the SAP |
|---|---|---|
%merge_subject | variables of another data set, by subject (PROC SORT, MERGE) | ::: {.copyvars dataset="..." variables="..." from="ADSL"} |
%pick | the value of the first or last record of each subject in another data set (PROC SORT, MERGE) | ::: {.derive variable="..." rule="..." from="..." first="..."} |
%summarize | the sum or mean of the records of each subject in another data set (PROC SUMMARY) | ::: {.derive variable="sum(...)" rule="..." from="..."} |
%flag_if_any | the subjects with a record that meets a condition (PROC SORT, MERGE) | ::: {.flag variable="..." condition="..." from="..."}, any DOMAIN(...) |
%flag_first | the first or last record of each group among those that meet a condition (PROC SORT, MERGE) | ::: {.flag variable="..." condition="..." mode="first" by="..." order="..."} |
%windows, %visits | the analysis window of each record by its day (the windows are a small table in the program); the analysis visit by the visit | windows |
%baseline | the baseline value of the group on every record (PROC SORT, MERGE) | baseline |
%locf | new records with DTYPE = 'LOCF' for each window without an analysed record | impute |
%pool | small groups (sites) into one pooled group (PROC FREQ, PROC SUMMARY) | pooled groups |
%relchange | new records of the change from the record before, relative to the normal range (PROC SORT, LAG) | relativechange |
%summarize_groups | one record per group with the sum, mean, largest or smallest value of the records (PROC SUMMARY) | parameter (a summary parameter) |
%param_wide | the records of several parameters of a group side by side (PROC SORT, MERGE) | computed (a parameter from other parameters) |
%tte | one time-to-event parameter: the first event of each subject, else the latest censoring; with end= in an at-risk period, else its end (PROC SORT, MERGE) | timetoevent |
One time-to-event parameter in one call: %tte#
A timetoevent block of the plan is one call of %tte, and the call says the whole parameter: whose records, which
records are events, their date and their order on one date, the period, the censoring, the descriptions and the
traceability. The parameters are then set together into ADTTE. The plan
::: {.timetoevent parameter="ADTTE.DIL3X" start="TRTSDT" end="DCUTDT"}
[]{.event from="ADLB" records='PARAMCD in ("ALT", "AST") and R2A1HI >= 3' date="ADT" priority="PARAMCD" set='EVNTDESC = "ALT/AST >= 3x ULN"'}
[]{.censor date="DCUTDT" censor="1" set='EVNTDESC = "Cutoff date"'}
:::
is this call in the program of ADTTE (work.subjects is ADTTE as the plan derives it before its first parameter):
%tte(
subjects = work.subjects
, events = adam.adlb
, event = PARAMCD in ('ALT', 'AST') and (not missing(R2A1HI) and R2A1HI >= 3)
, date = ADT
, priority = PARAMCD
, start = TRTSDT
, end = DCUTDT
, censor_date = DCUTDT
, censor_desc = 'Cutoff date'
, censor_source = 'ADSL'
, event_desc = 'ALT/AST >= 3x ULN'
, source = 'ADLB'
, seq = ASEQ
, paramcd = 'DIL3X'
, out = work.tte_dil3x
)
The rule (the at-risk period of each subject from start= to end=; an event on the start day; with time= and
start_time= not before the start time; on one date the order of priority=; else the latest censoring in the period;
else the end of the period with end_desc= and end_cnsr=) is the macro's, and its HELP says it. A subject without a
start date or without an end date gets no record and a line WARNING: [tte] DIL3X: no start date (TRTSDT): no record USUBJID=... in the log (the source writes PUT "WAR" "NING: [tte] ..." USUBJID=;, so that a scan of the log finds
only real messages), and sapling sas adam run shows it under the program; the R program names the same
subjects in a message ([tte] ADTTE.DIL3X: no record for the subject(s) without a start date (TRTSDT): ...), which sapling adam data shows (a fact of the data, not an error of the
program: the R run stays CLEAN). Without end= the macro keeps
the rule of the plans before it: the earliest event, else the latest censoring, a date before the start is the start
(as admiral).
A parameter with several event rows or several censoring rows (the earliest event among the records of several data
sets, the latest of several censorings) is still one call: the program first writes one DATA step for the records of
each row (its condition, its date as ADT, its values; _row its place in the plan) and stacks them, so the call reads
one data set of events and one of censorings:
* TTSD: the records of each row of the plan, in one data set of events and one of censorings;
data work.tte_ttsd_events1;
set adam.adae;
if AESER = 'Y' and (not missing(ASTDT) and not missing(TRTSDT) and ASTDT >= TRTSDT);
_row = 1;
ADT = ASTDT;
EVNTDESC = 'Serious event';
...
run;
...
%tte(
subjects = work.subjects
, events = work.tte_ttsd_events
, date = ADT
, priority = _row
, censors = work.tte_ttsd_censors
, censor_date = ADT
, censor_cnsr = CNSR
, start = STARTDT
, keep = EVNTDESC SRCDOM SRCSEQ
, paramcd = 'TTSD'
, out = work.tte_ttsd
)
A DATA step per row keeps each row readable where it is (one condition, one date), and the call stays the same for one row or five; repeated calls would need a step after them that chooses among their records, which is the rule of the macro written twice.
Every step kind of the registry (src/adam_steps.ts; adam-from-sap.md, "The commands") has its
SAS: the full pilot plan (examples/cdiscpilot01/pilot-adam.qmd, 171 steps in 10 data sets) and the dummy hypertension
study are made with no step NOT MADE. A step is NOT MADE in SAS only when the plan gives it in R alone: a hook of
custom/<ds>.R without its twin in custom/<ds>.sas (the message names the macro to write), or one line of R in the
SAP (r=) without its line of SAS (sas=).
The programs are made from the same derivations of the SAP (with the rules of adam-spec.qmd) as the R programs. The
data set ends with a DATA step that gives each variable the length and label of the specification, and a sort by the keys.
Three rules keep SAS equal to R: a comparison with a missing value is false (SAS alone orders a missing value below every number, so a comparison carries its missing checks); a sort key puts a missing value last; arithmetic is done only where its values are there (so the log has no note on missing values).
sapling sas adam run compares each data set with R's by PROC COMPARE (every value, criterion 1E-9); the listing is in
out/sas/adam/compare/. EQUAL VALUES means only an attribute differs (a transport file gives a date an informat).
The programmer's hooks in SAS#
A hook of the programmer (customize.md) can be written in SAS as well as in R: custom/<ds>.sas beside
custom/<ds>.R. Each hook is a macro whose name says where it runs, with the working data set as data= (changed in
place) and the pinned fingerprint of its generated input as the default of input=; the marks are comment lines above
it:
* drafted by: agent (2026-11-12, the SAS twin of hook_override("DURDIS"));
%macro hook_override_durdis(data=, input=d8fc597d);
data &data.;
set &data.;
if nmiss(DISONSDT, VISIT1DT) = 0 then
DURDIS = intck('month', DISONSDT, VISIT1DT, 'c');
run;
%mend hook_override_durdis;
| Hook of R | Its twin in SAS |
|---|---|
hook_before("ADQSADAS.WINDOWS", f), hook_after(...) | %macro hook_before_adqsadas_windows(data=), hook_after_... (the step id in lower case, . as _) |
hook_override("DURDIS", f) | %macro hook_override_durdis(data=) |
hook_add("adsl_wtloss", f) (custom="adsl_wtloss" in the SAP) | %macro hook_add_adsl_wtloss(data=) |
hook_filter("screen", f) | %macro hook_filter_screen(data=) |
The same code of Sapling binds both (src/custom.ts): the same errors for a hook that names no step, the same
fingerprint of the generated input, the same drift report and custom report (sapc custom-report), the same marks
(drafted by:, approved by:, a person only). The SAS program includes custom/<ds>.sas and calls each macro at its
step; a pinned input that differs from the generated one writes a WARNING in the log of the run. sapling sas adam run copies custom/*.sas to the SAS server with the programs.
No PASS for SAS that never ran#
sapling sas adam run ends SAS ADAM RUN OPEN, not PASS, while a part of its programs never ran on a SAS server on
its fixtures, even when every data set equals R's: a step kind without sasRun in the registry, or a macro of
SAS_ADAM_NOT_RUN_MACROS (src/adam_sas.ts); the verdict line names them. node tools/adam_steps_sas.ts runs every
fixture of every step kind on the SAS server of DataStep Studio (made-up records written as DATA steps; no data of a
study; the macro library from ~/sapling/fixtures/macros) and compares every value with the expected records, as the R
test does (ADAM STEPS SAS PASS / FAIL / UNAVAILABLE); --write DIR writes the SAS of each fixture without running it.
A person writes sasRun of a step kind (where and when it passed) after reading that run.
Since 2026-11-12 every step kind has its sasRun (ADAM STEPS SAS PASS: 32 step kinds, 36 fixtures, 210 records, with
the %tte of one call per parameter) and
the list of macros not yet run is empty; the checks of %pick unique=yes and of %baseline (two records of a subject
or of a group: an ERROR) ran on made-up records.
(none)
What is made#
out/sas/
programs/<output>.sas the program of each output
programs/00_import.sas the ADaM transport files as SAS data sets
programs.json the outputs, what SAS does not make, and the link of each result to the plan
macros/ the macro library (a copy of sas/macros/): the package is complete without Sapling
flat/<output>.sas with --flat: the program as plain SAS without any macro, proved to run alone
tlf/<output>.rtf, .lst the table: RTF and LISTING (monospace), both from %print
ard/<output>.csv every number with its result, statistic and groups
listings/, figures/ the listings (.rtf, .lst and CSV) and figures (PNG, with their numbers as CSV)
logs/, check.md the log of each run; SAS against R
For a reviewer#
out/sas/programs/ and out/sas/macros/ are complete: a reviewer (for example at FDA) copies both folders next to the
ADaM data, changes the one line %let spl_root = ...; at the top of a program and runs it. Nothing of Sapling or
DataStep Studio is needed. The macros are part of the package and are written to be read: one macro per file, a header
that says what it does, the procedure in the macro, steps with comments.
When a reviewer asks for code without macros, sapling sas run --flat also writes out/sas/flat/<output>.sas: the
same program as plain SAS, without a macro call, a macro definition or an %INCLUDE. SAS itself writes the code its
macros made (options MPRINT and MFILE); Sapling puts it in the place of each macro call, formats it, runs it alone
and compares its numbers with the macro program's, byte for byte. Two folders are set at its top (%LET adamdir,
%LET outdir). A flat program is long, and it is the code for the data it was made from; it is made again with every
run.
Where SAS runs, and your data#
SAS runs in your DataStep Studio session (the datastep command of the extension; DATASTEP_BIN or datastep: in
sapling.yml names another). You see every run in its window. The study's folder on the server is ~/sapling/<study>
(sas.root: in sapling.yml).
sapling sas run copies the ADaM transport files to that SAS server. For dummy data (sdtm/DUMMY.md) it does so
without a question. For the data of a study it refuses until you give --upload-data (or sas.upload: yes in
sapling.yml): only you know whether that server may hold them.
A program's verdict is in the log of its run: FAIL on an error (a macro writes ERROR: [macro] ...), NOT MADE
when the plan has analyses SAS does not make, else DONE.
Limits#
- A method without a SAS macro, and groups by a condition that is not one value of a variable, are not made (see above); the number at risk of a Kaplan-Meier summary is not made.
- A category of the plan without any record shows with n = 0 only when the plan lists it (
levels=); the groups of a test or a model are the values in the data. - A step of the ADaM in R alone (a hook of R without its SAS twin,
r=withoutsas=) isNOT MADEin SAS, named in the program and insapling sas adam programs. - The figures of SAS and of R show the same numbers; they are not the same picture (two graphics systems).
- The table layout of SAS and of R differ in spacing; the numbers and their texts are the same.