ids <- c("SYN201", "SYN202", "SYN203")
cohort <- new_cohort_record(
members = data.frame(
patient_id = ids, stage_id = "selected", included = TRUE,
reason = NA_character_
),
stages = data.frame(
stage_id = "selected", order = 1L, label = "Selected",
source = "upstream", provenance = "synthetic selection"
),
patient_id_col = "masterid"
)
path <- tempfile(fileext = ".yaml")
yaml::write_yaml(list(
study = "synthetic",
cohort_table = "synthetic.cohort",
warehouse = "synthetic_warehouse",
view_schema = "dbo",
pull_date = "2026-10-01",
modules = list("base", "vitalstatus"),
derive = list(scale_value = TRUE)
), path)
config <- read_study_config(path)
unlink(path)
base <- data.frame(
masterid = ids, base_value = c(3, 5, 8), keep = c(TRUE, FALSE, TRUE)
)
status <- data.frame(
masterid = ids, status_value = c("alive", "alive", "deceased")
)
raw <- structure(list(
tables = list(bdbase = base, bdstat = status),
manifest = data.frame(
module = c("base", "vitalstatus"), output = c("bdbase", "bdstat"),
n_rows = c(3L, 3L), n_cols = c(3L, 2L),
pulled_at = as.POSIXct(rep("2026-10-01", 2L), tz = "UTC")
)
), class = "pull_result")
built <- build_dataset(raw, config, cohort)
study_derive <- function(data, cohort, config) {
keep <- data$keep
if (isTRUE(config$derive[["scale_value"]])) {
data$analysis_value <- data$base_value * 2
}
reason <- rep(NA_character_, length(keep))
reason[!keep] <- "synthetic study exclusion"
stage <- data.frame(
stage_id = "study_eligibility",
order = max(cohort$stages$order) + 1L,
label = "Study eligibility",
source = "derive",
provenance = "synthetic study rule"
)
outcomes <- data.frame(
patient_id = data$masterid, included = keep, reason = reason
)
updated <- append_cohort_stage(cohort, stage, outcomes)
list(data = data[keep, , drop = FALSE], cohort = updated)
}
derived <- derive_vars(built, config, study_derive)
cohort_summary(derived$cohort)$stages
#> stage_id label n_in n_excluded n_remaining
#> 1 selected Selected 3 0 3
#> 2 build Patient-level build 3 0 3
#> 3 study_eligibility Study eligibility 3 1 2Who this is for
You know the tp.bd.* and tp.vars.* templates. You now have to read and write R. This guide will not teach you R. Its job is to make what you already know transferable, and to warn you about the handful of places where a reasonable translation from SAS silently produces a different, plausible number rather than an error.
Read the traps section even if you skip everything else.
The model shift
A SAS program mutates a dataset through a sequence of steps, and the program itself is the record of what happened. If an analyst commented out a block with %macro skip, that decision lives only in their copy of the script.
R passes values through functions. The study’s study.yaml records warehouse modules and named shared derivation flags. Study-specific predicates and variable formulas live in committed R code passed to derive_vars(). The cohort_record carries upstream selection and later exclusions through the build, in order. Enabling a shared step is a configuration change; changing a study-specific rule is a code change.
Idiom translation
| SAS | R |
|---|---|
%include "<dbcreds>.sas" + CONNECT TO ODBC
|
dw_connect() |
PROC SQL; SELECT ... FROM connection to ODBC |
dw_pull(config, conn) |
tp.bd.data.master.sas |
build_dataset(raw, config, cohort) |
libname library "&STUDY/datasets" |
paths in study.yaml
|
%macro skip; ... %mend skip; |
modules: / named shared derive: flags in study.yaml
|
%vars(in=built, out=built, transf=1) |
derive_vars(build, config, study_derive) |
data x; set y; ... run; |
a dplyr pipeline |
PROC CONTENTS |
hvtiRutilities::data_dictionary() |
label x = 'Age at surgery'; |
labelled + hvtiRutilities::label_map()
|
first.id / last.id
|
group_by() + slice_head() / slice_tail()
|
libname out xport then read.xport()
|
parquet, or just return the object |
Build and derive with study-specific code
This synthetic example shows the boundary between warehouse assembly and local study rules. The build carries the selected cohort forward. The callback adds a study variable, records its exclusion stage, and returns only included rows.
Keep study rules in version-controlled R code. Do not put executable R expressions or source paths in study.yaml. Explicitly resolve missing predicate values before appending outcomes: a missing included value is an error, not an implicit pass or exclusion.
Port the downstream dataset, not its first consumer
A bd.SAStoR.sas job, often copied from tp.bd.SAStoR.sas, can look like a file-format adapter: it reads built, keeps a shorter variable list, and writes builtr for R. The useful boundary is not SAS versus R, however. The output is a downstream data contract that may feed descriptive summaries, models, tables, and figures long after the first job that happened to read it.
Port that pattern as an analysis set. Declare the columns and any row exclusions once under analysis_sets: in the setup manifest, _study.yml, then let every downstream job that needs that contract read the same checkpoint. This is not the warehouse-build study.yaml read by read_study_config():
analysis_sets:
primary_analysis:
id: patient_id
vars:
- patient_id
- surgery_year
- biomarker
- event
- follow_up
event: event
expect:
n: 1200The names and counts above are synthetic. Name a set for the dataset contract, such as primary_analysis, rather than for its first consumer, such as a particular plot. Add another set only when the study has a different population or variable contract. If the SAS step keeps every row, omit exclude; the vars list alone expresses its projection. event names the set’s own event column, which must be one of its vars; from it write_analysis_set() calculates n_events and n_censored, and only a set with an event may add expectations for them.
Write the checkpoint explicitly after the built dataset is ready:
cfg <- hvtiRutilities::study_config()
write_analysis_set("primary_analysis", cfg)Downstream jobs read it; they do not cut their own copies or silently rebuild a stale one:
d <- read_analysis_set("primary_analysis", cfg)Keep derivation on the build side of this boundary. If the SAS job calls a vars macro before its final keep, port those new or transformed variables to the build or derived-variable layer. An analysis set selects rows and columns from built; it does not create variables. This separation leaves one reviewable derivation path and one reviewable downstream contract, without a study-specific package function for either variable list.
The traps
Each of these produces a wrong number rather than an error. They are the reason compare_built() exists.
Missing sorts low
In SAS a missing numeric orders below every number. So this marks a patient with unknown BMI as underweight:
if bmi < 18.5 then underweight = 1;
R does not:
bmi <- c(31.2, NA, 17.0)
bmi < 18.5
#> [1] FALSE NA TRUENA propagates instead of comparing as small. R is right and SAS is wrong, so a faithful port will fail equivalence here. That is what the intentional_divergence resolution in equivalence_signoff.yaml is for. Do not reproduce the SAS behaviour to make the comparison green.
Character comparison is blank-padded in SAS
SAS pads character values to their declared length, so 'Smith' and 'Smith ' compare equal. In R they do not:
"Smith" == "Smith "
#> [1] FALSE
trimws("Smith ") == "Smith"
#> [1] TRUEThis bites hardest on merges keyed by a character id. compare_built() trims before comparing, for exactly this reason.
SAS has no missing character value
R distinguishes NA from "". SAS does not: a missing character is the empty string. Read a SAS dataset and every missing character comes back as "":
f <- system.file("extdata", "oracle_small.sas7bdat",
package = "hvtiRdatabuild")
surgeon <- haven::read_sas(f)$surgeon
surgeon # the fourth value was written as NA
#> [1] "Smith" "Jones" "Smith" ""
is.na(surgeon) # ... and is not NA any more
#> [1] FALSE FALSE FALSE FALSESo a faithful R rebuild holding NA disagrees with the oracle holding "" on every missing value. compare_built() folds "" to NA for character columns so this does not flood the report with false differences.
The consequence for your own code is sharper: never test a SAS-sourced character for NA. is.na(surgeon) is FALSE even where the value is missing. Test for "", or convert on read.
MERGE is not a join
A SAS data-step MERGE on a non-unique BY key does not error. It produces undefined results. This is not hypothetical. The revision history of tp.bd.data.master.sas records it:
07/03/23: Changed the join logic for the FUP dataset to correct many-to-1 join problem when patient has multiple surgeries in the dataset
R makes the same mistake loud:
a <- data.frame(id = c("A", "A"), x = 1:2)
b <- data.frame(id = c("A", "A"), y = 3:4)
nrow(dplyr::left_join(a, b, by = "id"))
#> Warning in dplyr::left_join(a, b, by = "id"): Detected an unexpected many-to-many relationship between `x` and `y`.
#> ℹ Row 1 of `x` matches multiple rows in `y`.
#> ℹ Row 1 of `y` matches multiple rows in `x`.
#> ℹ If a many-to-many relationship is expected, set `relationship =
#> "many-to-many"` to silence this warning.
#> [1] 4Four rows from two, and dplyr says so out loud, which SAS never did.
You can silence that warning with relationship = "many-to-many", and there are joins where the fan-out is genuinely intended. But reach for it only once you have confirmed the duplication is what you want. Do not pass it reflexively to quiet the output; that turns off the one check SAS never gave you.
Dates use different origins
SAS counts days from 1960-01-01; R from 1970-01-01. The gap is 3,653 days. haven converts correctly, so a value read through snapshot_oracle() is already right. A hand-rolled conversion is where this goes wrong.
Automatic variables do not exist in R
_N_, _FREQ_, and _TYPE_ are created by SAS. In R you construct them, and the name is not magic. Phase 0 of this migration found a defect of exactly this class: keep _freq_ tau in one file versus keep freq tau in its sibling. _FREQ_ is the automatic variable; freq is an ordinary one that may not exist. One of those programs produced wrong counts.
length 4 silently truncates
A SAS numeric written with length 4 loses precision on disk. A value read back from an old dataset may not equal a freshly computed R value, and the SAS side is the lossy one. tolerance in compare_built() is what catches this, but it is an absolute threshold, not a relative one. A single absolute threshold cannot serve columns of mixed magnitude: a value large enough to be at risk of length 4 truncation (into the millions, for identifiers or accumulated counts) can differ by more than a small absolute tolerance while the difference is still storage precision, not a real disagreement. 1234567 vs 1234567.06 reports differs, not within_tolerance, with max_abs_diff of 0.06, but max_rel_diff is 4.9e-08. For a large-magnitude variable, that tiny relative difference alongside a differs verdict is the signal to suspect storage precision, not the within_tolerance verdict itself. Read max_rel_diff, not the verdict alone, when judging whether a difference is real.
Verifying your own port
Build the variable in R, then compare against the oracle:
oracle <- data.frame(ccfidu = c("A1", "A2", "A3"), age = c(65.5, 70.2, 58.0))
mine <- data.frame(ccfidu = c("A1", "A2", "A3"), age = c(65.5, 70.2, 58.5))
compare_built(oracle, mine, id = "ccfidu")
#> Dataset comparison
#> rows: 3 oracle, 3 R, 3 common
#>
#> variables by verdict:
#> differs 1
#>
#> requiring review:
#> variable verdict n_differ max_abs_diff max_rel_diff detail
#> age differs 1 0.5 0.008547009There is no overall pass or fail, deliberately. Read the table, and record a resolution for every variable that is not identical.