Sapling SAP
Reference

The SAS side

The same outputs in SAS, the macro library, the ADaM data sets in SAS, and every number of R against SAS.

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
CommandVerdict
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 checkSAS 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:

The result shape#

Every statistics macro writes the same columns, one row per group and statistic:

ColumnIt holds
the group variablesas in the data: TRT01A, AEBODSYS, AEDECOD, PARAMCD, AVISIT ...; VERSUS: the group a difference is made with
GROUPSthe names of the group variables of the row, in order (TRT01A AEBODSYS AEDECOD)
BLOCKthe label of the block in a table
STATn, pct, events, mean, sd, median, min, max, q1, q3, se, pval, estimate, lcl, ucl, lsmean, lsmean_se, diff, diff_se
VALUEthe number, with every digit
TEXTthe 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 callThe procedure in itIt gives
%studyLIBNAME, OPTIONSthe ADaM library, the output folder, the name of the program
%describePROC MEANSn, mean, SD, median, min, max (Q1, Q3, SE when asked); total=yes
%freqPROC FREQsubjects 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)
%worstPROC SORT, DATA stepthe 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)
%termflagsPROC SORT, DATA stepone record per subject and term with FLAG Y or N (threshold=), before %risk(within=) (a comparison of each term of an adverse event table)
%anovaPROC GLMthe F test of a one-way analysis of variance
%ancovaPROC GLMthe Type III test of a term; its estimate and confidence limits
%lsmeansPROC MIXEDLS means and the differences of pairs (pairs=all with order=, or the pairs by value, separated by #)
%mmrmPROC MIXED, REPEATEDthe mixed model for repeated measures at one visit; the structures of fallback= when one does not converge
%chisq, %fisher, %cmhPROC FREQchi-square or Fisher's exact test; Fisher's test of subjects with a record against those without; Cochran-Mantel-Haenszel (statistic=rowmean)
%km, %logrankPROC LIFETESTKaplan-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)
%proportionPROC FREQ (BINOMIAL)subjects and percent per category with the confidence interval of the percent (ci=clopper-pearson, the default; wilson, wald, agresti-coull)
%riskPROC 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
%rateratioPROC GENMODthe 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
%ratePROC FREQ, PROC MEANSthe exposure-adjusted incidence rate of each group (per 100 patient-years) and its exact Poisson limits; count=events: the rate of the events
%ttestPROC TTESTthe difference of the means of each pair of groups, its SE, CI and p-value (variances=equal, the default, or unequal)
%wilcoxonPROC NPAR1WAYthe Wilcoxon rank-sum test of each pair (normal approximation, correction=yes) and the Hodges-Lehmann shift with its Moses limits
%adjustPROC MULTTEST (INPVALUES=), DATA stepthe 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
%logisticPROC LOGISTICthe odds ratio of pairs of groups from one fit (event=, covariates, class factors), its Wald limits and p-value
%coxPROC 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)
%stackDATA stepresults one under another: the blocks of a table
%columnsPROC SUMMARYthe 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)
%ardDATA stepsevery number of the results, with every digit, and the N of each column: ard/<output>.csv
%layoutDATA steps, PROC FORMAT, PROC TRANSPOSEthe 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.))
%printPROC REPORTthe printer: a listing or the display data set of a table, as <file>.rtf (ODS RTF, style journal) and <file>.lst (LISTING, monospace)
%tablenone 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
%csvDATA stepsthe 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, %scatterplotthe procedure of each, then one PROC SGPLOT or SGPANELthe 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:

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:

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:

%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.

MacroTableBlocks
%table_summarydemographics, baseline: vars= with # after a categorical variable%describe, %freq
%table_aeadverse events by the levels of var= (sort=frequency, threshold=)%freq
%table_visiteach variable of vars= by parameter and visit (post=chg: after baseline only)%describe
%table_responseresponders: percent with interval, measure=rd/rr/or, with covars= the adjusted odds ratio%proportion, %risk, %logistic
%table_modeldescriptive blocks (describe=) at the visit at=, then method=ancova or mmrm%describe, %lsmeans, %mmrm
%table_tteKaplan-Meier, log-rank test, hazard ratio%km, %logrank, %cox
%table_shiftthe 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.

WhatVerdict
every method of the registry on its known answerMETHODS SAS PASS: 29 method(s), 45 call(s), 360 numbers equal to the known answers
every step kind of the ADaM on its fixturesADAM 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 figuresSAS 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 SDTMSAS 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:

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:

WhatVerdict
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 figuresSAS RUN PASS: 12 program(s) ...; flat programs (no macro, out/sas/flat): 12 of 12 proved by their own run
the same, R against SASSAS 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 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:

WhatVerdict
his tests of %PRINTPRINT SAS PASS: 24 functional, 6 error cases
every method of the registry on its known answerMETHODS SAS PASS: 31 method(s), 50 call(s), 425 numbers equal to the known answers
every step kind of the ADaM on its fixturesADAM 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 figuresSAS RUN PASS: 12 program(s) ...; flat programs (no macro, out/sas/flat): 12 of 12 proved by their own run
the same, R against SASSAS 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 SDTMSAS 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:

WhatVerdict
the cases of %printPRINT SAS PASS: 8 cases
every method of the registry on its known answerMETHODS SAS PASS: 31 method(s), 50 call(s), 425 numbers equal to the known answers
every step kind of the ADaM on its fixturesADAM STEPS SAS PASS: 33 step kinds, 37 fixtures, 217 records equal to the expected records
the copy of SYNHTN01: 12 tables, 3 listings, 3 figuresSAS 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 SASSAS 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:

WhatVerdict
the 7 ADaM data sets made in SAS from SDTMSAS 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 figuresSAS 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 SASSAS 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:

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:

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

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

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)
MacroComputes withDrawsout=
%kmplotPROC LIFETEST (OUTSURV=, the quartiles), PROC SUMMARYthe 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 groupKIND 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 textFACTOR, LEVEL, PAIR, X, LOWER, UPPER, N
%meanplotPROC MEANSthe 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 axisX visit, Y, LOWER, UPPER, N
%lineplotnothinglines through the values given, the interval as bars or a bandX, Y, LOWER, UPPER
%boxplotPROC 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 markerN, MEAN, MEDIAN, Q1, Q3, MIN, MAX, WLOW, WHIGH
%barplotPROC FREQthe percent of the subjects (denom=) or records, or the count, grouped or stacked, the value on each barX category, Y, N, DENOM
%aeplot%termflags, %risk, PROC SUMMARYtwo panels on the terms: the percent of each group, the risk difference against reference= with its intervalKIND percent (X, N), difference (X, LOWER, UPPER: 100 x RD of %risk)
%profileplotPROC MEANSone thin line per subject in the colour of its group, mean=yes the group means as thick linesKIND subject (X, Y), mean (X, Y, N)
%scatterplotPROC REGthe points by group, fit=linear the line of each group, fit=loess SGPLOT's loess, identity=yes y = xKIND 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:

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:

MacroDrawing parameters (outpar=)Drawn from plot=
%kmplotcolumns by panel atrisk median level xlabel ylabel ref file width heightthe ticks are the X of the rows of KIND atrisk; level= only writes the heading of the medians
%forestplotcolumns estimate scale text xlabel ref file width heightthe order of the rows; each factor under its name (the label of its variable is not in out=); estimate= only names the text column
%meanplotcolumns by panel connect ntable xformat xlabel ylabel ref file width heighterror bars where LOWER has a value
%lineplotcolumns by panel band xformat xlabel ylabel ref file width heightan interval where LOWER has a value
%boxplotcolumns by panel outliers mean xformat xlabel ylabel ref file width heighteach box from its statistics (whiskers WLOW and WHIGH, Q1 to Q3, the median, the mean); as outliers only MIN and MAX beyond a whisker
%barplotcolumns by panel stacked labels xlabel ylabel ref file width heightthe categories in the order of their first row; one decimal on a bar unless every Y is its N
%aeplotcolumns term by xlabel file width heightthe terms in the order of their first row (sort= and max= choose the rows of out=: they compute)
%profileplotcolumns subject by panel mean xformat xlabel ylabel ref file width heightmean=no leaves out the rows of KIND mean
%scatterplotcolumns by panel identity xlabel ylabel ref file width heighta 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
CommandVerdict
sapling sas adam programsSAS 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):

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)
MacroIt doesStep of the SAP
%merge_subjectvariables of another data set, by subject (PROC SORT, MERGE)::: {.copyvars dataset="..." variables="..." from="ADSL"}
%pickthe value of the first or last record of each subject in another data set (PROC SORT, MERGE)::: {.derive variable="..." rule="..." from="..." first="..."}
%summarizethe sum or mean of the records of each subject in another data set (PROC SUMMARY)::: {.derive variable="sum(...)" rule="..." from="..."}
%flag_if_anythe subjects with a record that meets a condition (PROC SORT, MERGE)::: {.flag variable="..." condition="..." from="..."}, any DOMAIN(...)
%flag_firstthe first or last record of each group among those that meet a condition (PROC SORT, MERGE)::: {.flag variable="..." condition="..." mode="first" by="..." order="..."}
%windows, %visitsthe analysis window of each record by its day (the windows are a small table in the program); the analysis visit by the visitwindows
%baselinethe baseline value of the group on every record (PROC SORT, MERGE)baseline
%locfnew records with DTYPE = 'LOCF' for each window without an analysed recordimpute
%poolsmall groups (sites) into one pooled group (PROC FREQ, PROC SUMMARY)pooled groups
%relchangenew records of the change from the record before, relative to the normal range (PROC SORT, LAG)relativechange
%summarize_groupsone record per group with the sum, mean, largest or smallest value of the records (PROC SUMMARY)parameter (a summary parameter)
%param_widethe records of several parameters of a group side by side (PROC SORT, MERGE)computed (a parameter from other parameters)
%tteone 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 RIts 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#