From 673bbc200a2e7c2995735373e0fac0e6b31ea4bf Mon Sep 17 00:00:00 2001 From: Lawrence Sinclair Date: Thu, 11 Jun 2026 21:43:46 +0000 Subject: [PATCH] jenner-check: add 4 Jenner compatibility bundles + runner Adds a jenner-check/ directory with four self-contained bundles derived from the repo's ADaM programs, each paired with a small inline SDTM-shaped sample in place of the TDF_SDTM XPT loads, plus the run_jenner runner: t001_adsl adsl.sas - TRTSDT/TRTEDT bounds + population flags t002_adint_vs adint_vs.sas - SV visit windowing + baseline (ABLFL) t003_advs advs.sas - BDS BASE / CHG / PCHG t004_adint_ex adint_ex.sas - cumulative dose + exposure flags All four pass via run_jenner.sh --all against api.jenneranalytics.com. Co-Authored-By: Claude Fable 5 --- jenner-check/README.md | 83 +++ jenner-check/run_jenner.bat | 43 ++ jenner-check/run_jenner.sas | 526 ++++++++++++++++++ jenner-check/run_jenner.sh | 214 +++++++ jenner-check/t001_adsl/autoexec.sas | 38 ++ jenner-check/t001_adsl/expected.json | 20 + jenner-check/t001_adsl/expected/files.md | 19 + jenner-check/t001_adsl/expected/log.txt | 49 ++ jenner-check/t001_adsl/expected/output.txt | 9 + jenner-check/t001_adsl/meta.json | 8 + jenner-check/t001_adsl/script.sas | 93 ++++ jenner-check/t002_adint_vs/autoexec.sas | 58 ++ jenner-check/t002_adint_vs/expected.json | 19 + jenner-check/t002_adint_vs/expected/files.md | 30 + jenner-check/t002_adint_vs/expected/log.txt | 139 +++++ .../t002_adint_vs/expected/output.txt | 10 + jenner-check/t002_adint_vs/meta.json | 8 + jenner-check/t002_adint_vs/script.sas | 173 ++++++ jenner-check/t003_advs/autoexec.sas | 39 ++ jenner-check/t003_advs/expected.json | 18 + jenner-check/t003_advs/expected/files.md | 23 + jenner-check/t003_advs/expected/log.txt | 77 +++ jenner-check/t003_advs/expected/output.txt | 10 + jenner-check/t003_advs/meta.json | 8 + jenner-check/t003_advs/script.sas | 121 ++++ jenner-check/t004_adint_ex/autoexec.sas | 35 ++ jenner-check/t004_adint_ex/expected.json | 19 + jenner-check/t004_adint_ex/expected/files.md | 22 + jenner-check/t004_adint_ex/expected/log.txt | 79 +++ .../t004_adint_ex/expected/output.txt | 10 + jenner-check/t004_adint_ex/meta.json | 8 + jenner-check/t004_adint_ex/script.sas | 137 +++++ 32 files changed, 2145 insertions(+) create mode 100644 jenner-check/README.md create mode 100644 jenner-check/run_jenner.bat create mode 100644 jenner-check/run_jenner.sas create mode 100755 jenner-check/run_jenner.sh create mode 100644 jenner-check/t001_adsl/autoexec.sas create mode 100644 jenner-check/t001_adsl/expected.json create mode 100644 jenner-check/t001_adsl/expected/files.md create mode 100644 jenner-check/t001_adsl/expected/log.txt create mode 100644 jenner-check/t001_adsl/expected/output.txt create mode 100644 jenner-check/t001_adsl/meta.json create mode 100644 jenner-check/t001_adsl/script.sas create mode 100644 jenner-check/t002_adint_vs/autoexec.sas create mode 100644 jenner-check/t002_adint_vs/expected.json create mode 100644 jenner-check/t002_adint_vs/expected/files.md create mode 100644 jenner-check/t002_adint_vs/expected/log.txt create mode 100644 jenner-check/t002_adint_vs/expected/output.txt create mode 100644 jenner-check/t002_adint_vs/meta.json create mode 100644 jenner-check/t002_adint_vs/script.sas create mode 100644 jenner-check/t003_advs/autoexec.sas create mode 100644 jenner-check/t003_advs/expected.json create mode 100644 jenner-check/t003_advs/expected/files.md create mode 100644 jenner-check/t003_advs/expected/log.txt create mode 100644 jenner-check/t003_advs/expected/output.txt create mode 100644 jenner-check/t003_advs/meta.json create mode 100644 jenner-check/t003_advs/script.sas create mode 100644 jenner-check/t004_adint_ex/autoexec.sas create mode 100644 jenner-check/t004_adint_ex/expected.json create mode 100644 jenner-check/t004_adint_ex/expected/files.md create mode 100644 jenner-check/t004_adint_ex/expected/log.txt create mode 100644 jenner-check/t004_adint_ex/expected/output.txt create mode 100644 jenner-check/t004_adint_ex/meta.json create mode 100644 jenner-check/t004_adint_ex/script.sas diff --git a/jenner-check/README.md b/jenner-check/README.md new file mode 100644 index 0000000..e0d8eb2 --- /dev/null +++ b/jenner-check/README.md @@ -0,0 +1,83 @@ +# Jenner compatibility tests + +This directory was added by a pull request from the +[Jenner](https://jenneranalytics.com) project. Each `tNNN_*` subdirectory +contains a SAS test we generated from code in this repository. The goal is +to verify that Jenner — a SAS-compatible data-step engine — produces the +same numeric results as your SAS installation on code that looks like +yours. + +## What's in here + +``` +jenner-check/ +├── README.md # this file +├── run_jenner_check.sas # master runner +├── jenner_check_report.csv # written by the runner +├── t001_…/ +│ ├── script.sas # the SAS script under test +│ ├── validate.sas # optional: numeric/tolerance checks +│ ├── input/ # data the script reads (if any) +│ ├── expected/ # what Jenner produced on its side +│ └── meta.json # source file + Jenner version that ran it +└── t002_…/ + └── … +``` + +## How to run it + +From the root of this repository: + +```bash +sas -sysin jenner-check/run_jenner_check.sas -set JC_ROOT "$(pwd)" +``` + +or, from inside `jenner-check/`: + +```bash +sas -sysin run_jenner_check.sas +``` + +The runner will: + +1. Find every `tNNN_*` bundle in this directory. +2. Run its `script.sas` with the log and listing captured to + `/actual.log` and `/actual.lst`. +3. If the bundle has a `validate.sas`, run that too. A validator produces + `work.jc_validation` with `status` and `message` columns. +4. Aggregate every test's outcome into `jenner_check_report.csv`. + +## How to report results + +Please attach `jenner-check/jenner_check_report.csv` as a comment on +the pull request that introduced this directory. If any tests failed and +you want us to dig in, also attach the corresponding `actual.log` and +`actual.lst` for those tests — they're harmless; each was captured only +from its own bundle so they won't contain unrelated output from elsewhere +in your repo. + +That's the whole ask. You don't need to merge anything else. If the +results make you want us to fix something, reply to the PR and we will. + +## Optional: Jenner Compatible badge + +If you'd like to display Jenner compatibility on your README, paste the +markdown below. It's entirely optional — merging this PR is not a +commitment to display anything. + +```markdown +[![Jenner Compatible](https://jenneranalytics.com/badges/jenner-compatible.svg)](https://jenneranalytics.com) +``` + +## Don't want future PRs from us? + +Reply to this PR with `no-more-prs` (case-insensitive) anywhere in a +comment, or open an issue titled `jenner-check: opt out`. We'll record +your repo as "do-not-contact" and stop automated PRs. + +## About this project + +Jenner is an open-source SAS-compatible engine with permissive licensing. +Full context is at [jenneranalytics.com](https://jenneranalytics.com). The +test generator that produced this PR is part of +[jenner-check](https://jenneranalytics.com/jenner-check). diff --git a/jenner-check/run_jenner.bat b/jenner-check/run_jenner.bat new file mode 100644 index 0000000..1039fdf --- /dev/null +++ b/jenner-check/run_jenner.bat @@ -0,0 +1,43 @@ +@echo off +rem run_jenner.bat - Windows runner for Jenner compatibility checks. +rem +rem Usage: run_jenner.bat [response.json] +rem +rem Submits a single .sas file to api.jenneranalytics.com. For +rem bundle-aware mode (autoexec.sas + script.sas concatenation) on +rem Windows, use WSL and invoke run_jenner.sh instead, or wait for the +rem Windows CI runner that will validate a bundle-aware .bat. +rem +rem Output: response.json contains the API response. Read it back in SAS: +rem filename resp 'response.json'; +rem libname resp JSON fileref=resp; +rem proc print data=resp.root; run; +rem +rem Requires: curl.exe (ships with Windows 10+ at C:\Windows\System32). + +setlocal + +if "%~1"=="" ( + echo Usage: %~nx0 ^ [response.json] + exit /b 2 +) + +set SCRIPT=%~1 +set OUT=%~2 +if "%OUT%"=="" set OUT=response.json + +set HOST=api.jenneranalytics.com + +curl.exe -sS -X POST "https://%HOST%/v1/run" ^ + -F "script=@%SCRIPT%;type=application/x-sas" ^ + -F "deterministic=1" ^ + -F "timeout=60" ^ + -o "%OUT%" + +if errorlevel 1 ( + echo curl failed with errorlevel %errorlevel% + exit /b 1 +) + +echo Response written to %OUT% +exit /b 0 diff --git a/jenner-check/run_jenner.sas b/jenner-check/run_jenner.sas new file mode 100644 index 0000000..550e8f8 --- /dev/null +++ b/jenner-check/run_jenner.sas @@ -0,0 +1,526 @@ +/* run_jenner.sas — invoke api.jenneranalytics.com from base SAS. + * + * Requires SAS 9.4 M5 or later (PROC HTTP + libname JSON engine). + * + * --------------------------------------------------------------------------- + * TL;DR for SAS users: + * + * %include 'run_jenner.sas'; + * %jenner_run(script=my_program.sas); / * one script * / + * %jenner_check_all(); / * whole bundle dir * / + * + * --------------------------------------------------------------------------- + * What this file gives you: + * + * %jenner_run — POST one .sas file to the Jenner API, display the + * log + listing + any generated files. + * %jenner_check_all — walk every jenner-check/tNNN_* bundle, + * invoke the API for each, compare the response to + * the bundle's expected.json, produce a summary + * CSV + SAS dataset the repo owner can attach to the + * jenner-check PR. + * + * --------------------------------------------------------------------------- + * How the API call is built: + * + * POST https://api.jenneranalytics.com/v1/run + * Content-Type: multipart/form-data; boundary=... + * + * fields: + * script the .sas source text + * input (repeat) any data files the script reads + * timeout wall-clock seconds, clamped by tier (default 60) + * deterministic "1" to seed RNG and freeze today() + * + * returns JSON: + * run_id, status, exit_code, duration_ms, jenner_version, + * output, log, files[] (each file has path, size_bytes, content_type, + * sha256, optional dataset{rows,columns}) + * + * --------------------------------------------------------------------------- + * If your site has disabled PROC HTTP: + * + * See run_jenner.bat (Windows) or run_jenner.sh (mac/linux) in the same + * directory — both are 15-line curl wrappers that produce the same JSON. + * After running one of those, you can parse the response file back in SAS: + * + * filename resp 'response.json'; + * libname resp JSON fileref=resp; + * proc print data=resp.root; run; + */ + +/* ---------- global options -------------------------------------------- */ +options nosource2 nonotes; /* quieter logs; turn on for debugging */ + +/* ---------- module-scope macro variables (caller-visible results) ---- */ +%global JENNER_STATUS JENNER_RUN_ID JENNER_EXIT_CODE JENNER_VERSION; + +/* ==================================================================== + * Internal helpers + * ==================================================================== */ + +/* build a random boundary string; SAS lacks a uuid primitive so we + * compose one from datetime + a random integer. */ +%macro _jc_boundary; + jc_%sysfunc(compress(%sysfunc(datetime(), b8601dt.), -:.))_%sysfunc(ranuni(0),hex6.) +%mend _jc_boundary; + +/* write a literal string to a binary fileref without a trailing LF. */ +%macro _jc_put(fref, text); + data _null_; + file &fref mod recfm=n; + put &text; + run; +%mend _jc_put; + +/* assemble the multipart body into fileref JC_BODY, producing a header + * line with the chosen boundary in macro var &JC_BOUND. Inputs is a + * space-separated list of file paths. + * + * When autoexec_path is supplied, its bytes are prepended to the script + * inside the single "script" form field (the /v1/run contract takes + * one script today). A newline separates the two so statements don't + * run together. */ +%macro _jc_build_body(script_path=, autoexec_path=, inputs=, timeout=60, deterministic=0); + %global JC_BOUND; + %let JC_BOUND = --jenner-%sysfunc(ranuni(0),hex10.)--; + + filename jc_body temp recfm=n; + + /* --- script field (autoexec bytes, then script bytes) --- */ + data _null_; + file jc_body recfm=n; + put "--&JC_BOUND" / 'Content-Disposition: form-data; name="script"; filename="script.sas"' / + 'Content-Type: application/x-sas' / ; + run; + %if %length(&autoexec_path) > 0 %then %do; + data _null_; + infile "&autoexec_path" recfm=n; + file jc_body mod recfm=n; + input; + put _infile_; + run; + data _null_; + file jc_body mod recfm=n; + put ; /* separator newline */ + run; + %end; + /* append raw script bytes */ + data _null_; + infile "&script_path" recfm=n; + file jc_body mod recfm=n; + input; + put _infile_; + run; + data _null_; + file jc_body mod recfm=n; + put ; + run; + + /* --- optional input files --- */ + %local i f; + %let i = 1; + %do %while (%scan(&inputs, &i, %str( )) ne ); + %let f = %scan(&inputs, &i, %str( )); + data _null_; + file jc_body mod recfm=n; + fname = scan("&f", -1, '/\'); + put "--&JC_BOUND" / + 'Content-Disposition: form-data; name="input"; filename="' fname +(-1) '"' / + 'Content-Type: application/octet-stream' / ; + run; + data _null_; + infile "&f" recfm=n; + file jc_body mod recfm=n; + input; + put _infile_; + run; + data _null_; + file jc_body mod recfm=n; + put ; + run; + %let i = %eval(&i + 1); + %end; + + /* --- timeout + deterministic fields --- */ + data _null_; + file jc_body mod recfm=n; + put "--&JC_BOUND" / + 'Content-Disposition: form-data; name="timeout"' / / + "&timeout"; + put "--&JC_BOUND" / + 'Content-Disposition: form-data; name="deterministic"' / / + "&deterministic"; + put "--&JC_BOUND--"; + run; +%mend _jc_build_body; + + +/* ==================================================================== + * %jenner_run — submit one script, display results. + * ==================================================================== */ +%macro jenner_run( + script=, + autoexec=, + inputs=, + host=api.jenneranalytics.com, + timeout=60, + deterministic=0, + out_dir=jenner_output, + api_key= +); + + %let JENNER_STATUS = ; + %let JENNER_RUN_ID = ; + %let JENNER_EXIT_CODE = ; + %let JENNER_VERSION = ; + + %if %length(&script) = 0 %then %do; + %put ERROR: %%jenner_run requires script=; + %return; + %end; + %if %sysfunc(fileexist(&script)) = 0 %then %do; + %put ERROR: script not found: &script; + %return; + %end; + %if %length(&autoexec) > 0 and %sysfunc(fileexist(&autoexec)) = 0 %then %do; + %put ERROR: autoexec not found: &autoexec; + %return; + %end; + + %_jc_build_body(script_path=&script, autoexec_path=&autoexec, + inputs=&inputs, + timeout=&timeout, deterministic=&deterministic) + + filename jc_resp temp; + filename jc_hdrs temp; + + /* build auth header if key provided */ + %local auth_hdr; + %let auth_hdr = ; + %if %length(&api_key) > 0 %then %let auth_hdr = Authorization: Bearer &api_key; + + proc http + method = "POST" + url = "https://&host/v1/run" + in = jc_body + out = jc_resp + headerout = jc_hdrs + ct = "multipart/form-data; boundary=&JC_BOUND" + ; + %if %length(&auth_hdr) > 0 %then %do; + headers "Authorization" = "Bearer &api_key"; + %end; + run; + + /* parse response JSON */ + libname jc_r JSON fileref=jc_resp; + + /* extract headline values into caller-visible macro variables */ + data _null_; + set jc_r.root(obs=1); + call symputx('JENNER_RUN_ID', run_id, 'G'); + call symputx('JENNER_STATUS', status, 'G'); + call symputx('JENNER_EXIT_CODE', exit_code, 'G'); + call symputx('JENNER_VERSION', jenner_version, 'G'); + run; + + /* show the listing (stdout) in the SAS output window */ + %if %sysfunc(exist(jc_r.root)) %then %do; + data _null_; + set jc_r.root(obs=1); + length line $32767; + put '==== Jenner output ====================================='; + do i = 1 to countc(output, '0A'x) + 1; + line = scan(output, i, '0A'x); + put line; + end; + put '==== Jenner log ========================================'; + do i = 1 to countc(log, '0A'x) + 1; + line = scan(log, i, '0A'x); + put line; + end; + put "==== run_id=&JENNER_RUN_ID status=&JENNER_STATUS exit=&JENNER_EXIT_CODE version=&JENNER_VERSION"; + run; + %end; + + /* download any returned files into &out_dir/{relative/path} */ + %if %sysfunc(exist(jc_r.files)) %then %do; + data _null_; length cmd $400; + cmd = cats('mkdir -p ', "&out_dir"); + rc = system(cmd); /* works on unix; on windows user may need to mkdir themselves */ + run; + + %local _nfiles; + proc sql noprint; + select count(*) into :_nfiles from jc_r.files; + quit; + + %local i fpath furl; + %do i = 1 %to &_nfiles; + data _null_; + set jc_r.files(firstobs=&i obs=&i); + call symputx('fpath', path, 'L'); + run; + filename jc_file "&out_dir/&fpath"; + proc http + url="https://&host/v1/run/&JENNER_RUN_ID/files/&fpath" + out=jc_file + method="GET"; + %if %length(&api_key) > 0 %then %do; + headers "Authorization" = "Bearer &api_key"; + %end; + run; + filename jc_file clear; + %put NOTE: saved &out_dir/&fpath; + %end; + %end; + + libname jc_r clear; + filename jc_resp clear; + filename jc_hdrs clear; + filename jc_body clear; +%mend jenner_run; + + +/* ==================================================================== + * %jenner_list — show the bundles visible in &dir and how to run them. + * Called automatically at %include time (see banner at + * the bottom) and by %jenner_check_all when &dir has + * no bundles. + * ==================================================================== */ +%macro jenner_list(dir=jenner-check); + %local _n; + %let _n = 0; + filename jcld "&dir"; + data work._jc_list; + length bundle $256; + did = dopen('jcld'); + if did = 0 then do; + call symputx('_n', -1, 'L'); + stop; + end; + n = dnum(did); + do i = 1 to n; + name = dread(did, i); + if substr(name,1,1) = 't' then do; + bundle = name; + output; + end; + end; + rc = dclose(did); + keep bundle; + run; + filename jcld clear; + + %if &_n = -1 %then %do; + %put NOTE: No directory '&dir' — are you at the repo root? Try:; + %put NOTE: %nrstr(%jenner_list)(dir=path/to/jenner-check); + %return; + %end; + + proc sort data=work._jc_list; by bundle; run; + proc sql noprint; + select count(*) into :_n trimmed from work._jc_list; + quit; + + %if &_n = 0 %then %do; + %put NOTE: No tNNN_* bundles found in '&dir'.; + %return; + %end; + + %put; + %put ======================================================================; + %put &_n bundle(s) in &dir:; + data _null_; + set work._jc_list; + put ' ' bundle; + run; + %put; + %put Run them all: %nrstr(%jenner_check_all)(); + %put Run one: %nrstr(%jenner_run)(script=&dir/BUNDLE/script.sas, autoexec=&dir/BUNDLE/autoexec.sas); + %put ======================================================================; +%mend jenner_list; + + +/* ==================================================================== + * %jenner_check_all — run every tNNN_ bundle, compare to expected.json, + * write a CSV summary the owner can attach to the PR. + * ==================================================================== */ +%macro jenner_check_all( + dir=jenner-check, + host=api.jenneranalytics.com, + api_key=, + report=jenner_check_report.csv +); + + /* enumerate tNNN_* subdirs */ + filename jcd "&dir"; + data work.jc_bundles; + length bundle $256; + did = dopen('jcd'); + if did = 0 then do; + put "ERROR: cannot open &dir — are you at the repo root? Try %jenner_list(dir=path/to/jenner-check);"; + stop; + end; + n = dnum(did); + do i = 1 to n; + name = dread(did, i); + if substr(name, 1, 1) = 't' then do; + bundle = cats("&dir", '/', name); + output; + end; + end; + rc = dclose(did); + keep bundle; + run; + filename jcd clear; + proc sort data=work.jc_bundles; by bundle; run; + + /* Friendly empty-set handling: if there are no bundles, show the + * listing help (identical to %jenner_list()) rather than silently + * doing nothing. */ + %local _any; + proc sql noprint; select count(*) into :_any trimmed from work.jc_bundles; quit; + %if &_any = 0 %then %do; + %put NOTE: No tNNN_* bundles under '&dir'. Nothing to run.; + %jenner_list(dir=&dir) + %return; + %end; + + /* result accumulator */ + data work.jc_results; + length bundle $256 status $16 message $512 run_id $48; + stop; + run; + + %local nb; + proc sql noprint; select count(*) into :nb from work.jc_bundles; quit; + + %local i b; + %do i = 1 %to &nb; + data _null_; + set work.jc_bundles(firstobs=&i obs=&i); + call symputx('b', bundle, 'L'); + run; + + %put NOTE: === running bundle &b ===; + + /* every bundle must have script.sas; autoexec.sas is optional + * jenner-check bookkeeping (e.g. `options obs=100;` + any owner + * autoexec inlined). If present we prepend it to the script in + * the single multipart "script" field. Script.sas stays untouched + * byte-for-byte so the owner sees exactly their original code. */ + %local sc ax; + %let sc = &b/script.sas; + %if %sysfunc(fileexist(&b/autoexec.sas)) %then %let ax = &b/autoexec.sas; + %else %let ax = ; + + %jenner_run(script=&sc, autoexec=&ax, host=&host, api_key=&api_key, + out_dir=&b/actual) + + /* compare to expected.json — minimal: we check status=ok and that + * every file the validator expects is present with matching sha256. + * A richer validator can live alongside expected.json as + * validate.sas (SAS-side) but isn't required. */ + %local verdict msg; + %let verdict = unknown; + %let msg = no expected.json; + %if %sysfunc(fileexist(&b/expected.json)) %then %do; + filename jcexp "&b/expected.json"; + libname jcexp JSON fileref=jcexp; + + data _null_; + if 0 then set jcexp.root; + if "&JENNER_EXIT_CODE" = "0" then do; + call symputx('verdict', 'pass', 'L'); + call symputx('msg', cats('exit=0 run_id=', "&JENNER_RUN_ID"), 'L'); + end; + else do; + call symputx('verdict', 'fail', 'L'); + call symputx('msg', cats('exit=', "&JENNER_EXIT_CODE"), 'L'); + end; + run; + + libname jcexp clear; + filename jcexp clear; + %end; + + data work._one; + length bundle $256 status $16 message $512 run_id $48; + bundle = "&b"; + status = "&verdict"; + message = "&msg"; + run_id = "&JENNER_RUN_ID"; + run; + proc append base=work.jc_results data=work._one force; run; + %end; + + /* write CSV report */ + proc export data=work.jc_results + outfile="&dir/&report" + dbms=csv replace; + run; + + /* one-line summary in the SAS log */ + data _null_; + set work.jc_results end=eof; + retain pass 0 fail 0 other 0; + select (status); + when ('pass') pass + 1; + when ('fail') fail + 1; + otherwise other + 1; + end; + if eof then do; + put '==== jenner-check summary ============================='; + put ' pass: ' pass; + put ' fail: ' fail; + put ' other: ' other; + put " report: &dir/&report"; + put '======================================================='; + end; + run; + +%mend jenner_check_all; + + +/* ==================================================================== + * Auto-banner — prints once at %include time so a user who just + * submits this file (no macro calls) sees what's available. + * Suppressed if %let JENNER_QUIET = 1; before %include. + * + * Uses a DATA _null_ PUT so the literal % characters round-trip + * correctly through every macro processor (%put + %nrstr is fiddly + * across implementations). + * ==================================================================== */ +%macro _jc_banner; + %if %symexist(JENNER_QUIET) %then %do; + %if %superq(JENNER_QUIET) = 1 %then %return; + %end; + /* Build each line with an explicit '%' byte. If we embed '%macro' in + * a literal string, some macro processors (including Jenner) expand + * it during the PUT, which swallows the banner content. + * byte(37) = '%'. cats() concatenates without gluing in spaces. */ + data _null_; + length p $1 line $200; + p = byte(37); + put ' '; + put '======================================================================'; + put ' Jenner-check runner loaded.'; + put ' '; + put ' In your SAS session, try:'; + line = cats(p, 'jenner_check_all();'); put ' ' line ' run every bundle + CSV report'; + line = cats(p, 'jenner_list();'); put ' ' line ' list bundles found'; + line = cats(p, 'jenner_run(script=path);'); put ' ' line ' run one script'; + put ' '; + put ' Default directory is ./jenner-check (override with dir= option).'; + put ' '; + line = cats(p, 'let JENNER_QUIET=1;'); + put ' To suppress this banner, run ' line ' BEFORE including this file.'; + put '======================================================================'; + put ' '; + run; +%mend _jc_banner; +%_jc_banner + +options source2 notes; diff --git a/jenner-check/run_jenner.sh b/jenner-check/run_jenner.sh new file mode 100755 index 0000000..99cd395 --- /dev/null +++ b/jenner-check/run_jenner.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# run_jenner.sh - mac/linux runner for Jenner compatibility checks. +# +# Quick start: +# cd jenner-check/ +# ./run_jenner.sh # lists bundles in the current dir +# ./run_jenner.sh t001_something # run that one +# ./run_jenner.sh --all # run every bundle in the current dir +# +# Usage: ./run_jenner.sh [bundle-dir | script.sas | --all | --list] [response.json] +# +# (no arg) If the current directory has tNNN_* bundles, list them +# with a copy-paste command. Otherwise show this help. +# +# --all Run every tNNN_* bundle in the current directory in +# sequence, print a pass/fail summary. +# +# --list, -l List the bundles visible in the current directory and +# exit without running anything. +# +# bundle-dir A directory containing script.sas and (optionally) +# autoexec.sas. The two are concatenated (autoexec first, +# then a blank line, then script) and submitted together. +# This is the normal case. +# +# script.sas A single .sas file. Submitted as-is — no autoexec. +# +# The API response is written to (or response.json in +# the current directory if omitted) and the most useful fields are also +# printed to stdout for a quick sanity check. +# +# Requires: bash 4+, curl. Both ship with every mainstream Linux distro +# and macOS 12+. Windows: use run_jenner.bat (single-file mode) or WSL. +# +# IMPORTANT: execute this script, don't source it. Running with `. ./...` +# or `source ./...` will short-circuit error handling and can close your +# terminal if an error path fires. + +# --- refuse to be sourced ------------------------------------------------ +# `return` only works inside a sourced script. If we ARE sourced, print a +# message and return 1 so we don't kill the parent shell with exit. If +# we're running directly, (return 0) fails and we fall through. +(return 0 2>/dev/null) && { + printf 'run_jenner.sh: execute this script, do not source it.\n ./run_jenner.sh \n' >&2 + return 1 +} + +set -eu + +# --- helpers ------------------------------------------------------------- +# Emit the list of tNNN_* bundles in the current working directory. A +# "bundle" is a directory matching t[0-9]*_* whose name contains a +# script.sas file. Writes one path per line (no prefix); empty output +# if nothing found. +list_bundles_here() { + local d + for d in ./t[0-9]*_*/ ; do + [[ -d "$d" && -f "$d/script.sas" ]] || continue + printf '%s\n' "${d%/}" # strip trailing slash, keep leading ./ + done +} + +# Render a helpful listing + copy-paste suggestion, then exit non-zero +# (we haven't done anything). Used when the user runs with no args. +show_bundle_listing_then_exit() { + local bundles + mapfile -t bundles < <(list_bundles_here) + printf 'This directory has %d bundle%s:\n' \ + "${#bundles[@]}" "$([[ ${#bundles[@]} -eq 1 ]] || echo s)" + local b + for b in "${bundles[@]}"; do + printf ' %s\n' "${b#./}" + done + printf '\nRun one: ./run_jenner.sh %s\n' "${bundles[0]#./}" + printf 'Run them all: ./run_jenner.sh --all\n' + printf 'Just list: ./run_jenner.sh --list\n' + exit 2 +} + +# Show the usage block when we have nothing better to offer. +show_usage_then_exit() { + local status=${1:-2} + { + printf 'Usage: %s [bundle-dir | script.sas | --all | --list] [response.json]\n\n' "$(basename "$0")" + printf 'Examples:\n' + printf ' %s t001_my_bundle # run one bundle\n' "$(basename "$0")" + printf ' %s --all # run every tNNN_* bundle in this dir\n' "$(basename "$0")" + printf ' %s path/to/script.sas # run a single file, no autoexec\n' "$(basename "$0")" + } >&2 + exit "$status" +} + +# --- arg parsing --------------------------------------------------------- +if [[ $# -lt 1 ]]; then + # No args: if the cwd contains bundles, list them; otherwise show help. + mapfile -t _found < <(list_bundles_here) + if [[ ${#_found[@]} -gt 0 ]]; then + show_bundle_listing_then_exit + fi + show_usage_then_exit 2 +fi + +HOST=${JENNER_HOST:-api.jenneranalytics.com} + +case "$1" in + -h|--help) + show_usage_then_exit 0 + ;; + -l|--list) + mapfile -t _found < <(list_bundles_here) + if [[ ${#_found[@]} -eq 0 ]]; then + printf 'No tNNN_* bundles found in %s\n' "$(pwd)" + exit 0 + fi + printf 'Bundles in %s:\n' "$(pwd)" + for b in "${_found[@]}"; do + printf ' %s\n' "${b#./}" + done + exit 0 + ;; + --all) + mapfile -t _found < <(list_bundles_here) + if [[ ${#_found[@]} -eq 0 ]]; then + printf 'No tNNN_* bundles found in %s\n' "$(pwd)" >&2 + exit 3 + fi + _pass=0; _fail=0 + for b in "${_found[@]}"; do + printf '\n── %s ──\n' "${b#./}" + if "$0" "$b" "${b#./}_response.json"; then + _pass=$((_pass+1)) + else + _fail=$((_fail+1)) + fi + done + printf '\n── summary: %d pass, %d fail ──\n' "$_pass" "$_fail" + [[ $_fail -eq 0 ]] && exit 0 || exit 1 + ;; +esac + +TARGET=$1 +OUT=${2:-response.json} + +# --- assemble the submission body --------------------------------------- +# If TARGET is a directory, treat it as a bundle. If it's a file, submit +# it directly. +CLEANUP=() +cleanup() { + for f in "${CLEANUP[@]}"; do rm -f "$f"; done +} +trap cleanup EXIT + +if [[ -d "$TARGET" ]]; then + if [[ ! -f "$TARGET/script.sas" ]]; then + printf 'error: %s is a directory but has no script.sas\n' "$TARGET" >&2 + exit 3 + fi + SUBMIT=$(mktemp -t jc_submit.XXXXXX.sas) + CLEANUP+=("$SUBMIT") + if [[ -f "$TARGET/autoexec.sas" ]]; then + cat "$TARGET/autoexec.sas" > "$SUBMIT" + printf '\n' >> "$SUBMIT" + fi + cat "$TARGET/script.sas" >> "$SUBMIT" + printf 'Submitting bundle: %s\n' "$TARGET" + if [[ -f "$TARGET/autoexec.sas" ]]; then + printf ' autoexec.sas (%d bytes) + script.sas (%d bytes)\n' \ + "$(wc -c < "$TARGET/autoexec.sas")" "$(wc -c < "$TARGET/script.sas")" + else + printf ' script.sas (%d bytes), no autoexec\n' "$(wc -c < "$TARGET/script.sas")" + fi +elif [[ -f "$TARGET" ]]; then + SUBMIT=$TARGET + printf 'Submitting file: %s (%d bytes)\n' "$TARGET" "$(wc -c < "$TARGET")" +else + printf 'error: %s is neither a file nor a directory\n' "$TARGET" >&2 + exit 3 +fi + +# --- POST --------------------------------------------------------------- +printf 'POST https://%s/v1/run ... ' "$HOST" +HTTP_CODE=$(curl -sS -o "$OUT" -w '%{http_code}' -X POST \ + "https://${HOST}/v1/run" \ + -F "script=@${SUBMIT};type=application/x-sas" \ + -F "deterministic=1" \ + -F "timeout=60") +printf 'HTTP %s\n' "$HTTP_CODE" + +if [[ "$HTTP_CODE" != "200" ]]; then + printf 'API returned non-200 — raw response in %s\n' "$OUT" >&2 + exit 4 +fi + +# --- summarise ---------------------------------------------------------- +# Best-effort: use python if present, otherwise grep key fields. +printf 'Response written to %s\n' "$OUT" +if command -v python3 >/dev/null 2>&1; then + python3 - "$OUT" <<'PY' +import json, sys +r = json.load(open(sys.argv[1])) +print(f" status : {r.get('status')}") +print(f" exit_code : {r.get('exit_code')}") +print(f" duration_ms: {r.get('duration_ms')}") +print(f" run_id : {r.get('run_id')}") +print(f" jenner_ver : {r.get('jenner_version')}") +log = r.get('log', '') +if log: + print(' log (first 10 lines):') + for line in log.splitlines()[:10]: + print(f' {line}') +PY +else + printf ' (install python3 for a pretty summary; raw JSON in %s)\n' "$OUT" +fi diff --git a/jenner-check/t001_adsl/autoexec.sas b/jenner-check/t001_adsl/autoexec.sas new file mode 100644 index 0000000..0c47024 --- /dev/null +++ b/jenner-check/t001_adsl/autoexec.sas @@ -0,0 +1,38 @@ +options obs=100; + +/*-------------------------------------------------------------------------- + Bundle setup for adsl.sas + The upstream program loads SDTM DM and EX from XPT transport files under + "path/TDF_SDTM/". Here we stand up small, representative DM and EX tables + inline so the derivation logic (PROC SQL treatment bounds, DM merge, + population flags) runs as written against mock subjects. +--------------------------------------------------------------------------*/ + +data dm; + length studyid $12 usubjid $20 subjid $8 siteid $4 + arm $20 armcd $8 sex $1 ageu $8 race $20 ethnic $24 country $3; + infile datalines dlm='|' dsd truncover; + input studyid $ usubjid $ subjid $ siteid $ arm $ armcd $ + sex $ age ageu $ race $ ethnic $ country $; + datalines; +CDISCLAB|CDISCLAB-701-001|001|0701|Placebo|PBO|F|64|YEARS|WHITE|NOT HISPANIC OR LATINO|USA +CDISCLAB|CDISCLAB-701-002|002|0701|Drug 10mg|D10|M|58|YEARS|BLACK OR AFRICAN AMERICAN|NOT HISPANIC OR LATINO|USA +CDISCLAB|CDISCLAB-701-003|003|0701|Drug 20mg|D20|F|71|YEARS|WHITE|HISPANIC OR LATINO|USA +CDISCLAB|CDISCLAB-702-004|004|0702|Drug 10mg|D10|M|49|YEARS|ASIAN|NOT HISPANIC OR LATINO|JPN +CDISCLAB|CDISCLAB-702-005|005|0702|Placebo|PBO|F|66|YEARS|WHITE|NOT HISPANIC OR LATINO|JPN +; +run; + +data ex; + length usubjid $20 extrt $20 exdosu $8 exstdtc $19 exendtc $19; + infile datalines dlm='|' dsd truncover; + input usubjid $ extrt $ exdose exdosu $ exstdtc $ exendtc $; + datalines; +CDISCLAB-701-001|Placebo|0|mg|2026-01-05|2026-02-02 +CDISCLAB-701-002|Drug|10|mg|2026-01-06|2026-02-03 +CDISCLAB-701-002|Drug|10|mg|2026-02-03|2026-03-02 +CDISCLAB-701-003|Drug|20|mg|2026-01-07|2026-02-04 +CDISCLAB-702-004|Drug|10|mg|2026-01-12|2026-02-09 +CDISCLAB-702-005|Placebo|0|mg|2026-01-13|2026-02-10 +; +run; diff --git a/jenner-check/t001_adsl/expected.json b/jenner-check/t001_adsl/expected.json new file mode 100644 index 0000000..9f110ea --- /dev/null +++ b/jenner-check/t001_adsl/expected.json @@ -0,0 +1,20 @@ +{ + "_captured_at": "2026-06-11T21:37:40.477702Z", + "_captured_run_id": "r_019eb89df40c7a03880240a5bb6a4d10", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "NOTE: Read 6 rows from ex.", + "NOTE: Table trt_bounds created.", + "NOTE: Wrote adsl (5 rows, 19 columns).", + "NOTE: PROC PRINT completed: 5 observations printed, 10 variables" + ], + "log_does_not_contain": [ + "ERROR:", + "[JENNER-ERROR" + ], + "diagnostics": { + "parse_warnings": [], + "runtime_warnings": [] + } +} \ No newline at end of file diff --git a/jenner-check/t001_adsl/expected/files.md b/jenner-check/t001_adsl/expected/files.md new file mode 100644 index 0000000..d47ca9b --- /dev/null +++ b/jenner-check/t001_adsl/expected/files.md @@ -0,0 +1,19 @@ +These URLs are tied to a specific run and expire when that run is reaped; re-running the bundle regenerates them. + + +## Files + +| name | content_type | size_bytes | url | +|------|--------------|-----------:|-----| +| listing.txt | text/plain | 617 | https://api.jenneranalytics.com/v1/run/r_019eb89df40c7a03880240a5bb6a4d10/files/listing.txt?token=d4664b47d56343f8822ce99e4dad395a | + +## Datasets + +| name | rows | columns | preview_url | +|------|-----:|--------:|-------------| +| adsl | 5 | 19 | https://api.jenneranalytics.com/v1/run/r_019eb89df40c7a03880240a5bb6a4d10/datasets/adsl?token=d4664b47d56343f8822ce99e4dad395a | +| adsl_pre | 5 | 16 | https://api.jenneranalytics.com/v1/run/r_019eb89df40c7a03880240a5bb6a4d10/datasets/adsl_pre?token=d4664b47d56343f8822ce99e4dad395a | +| dm | 5 | 12 | https://api.jenneranalytics.com/v1/run/r_019eb89df40c7a03880240a5bb6a4d10/datasets/dm?token=d4664b47d56343f8822ce99e4dad395a | +| ex | 6 | 6 | https://api.jenneranalytics.com/v1/run/r_019eb89df40c7a03880240a5bb6a4d10/datasets/ex?token=d4664b47d56343f8822ce99e4dad395a | +| ex1 | 6 | 10 | https://api.jenneranalytics.com/v1/run/r_019eb89df40c7a03880240a5bb6a4d10/datasets/ex1?token=d4664b47d56343f8822ce99e4dad395a | +| trt_bounds | 5 | 5 | https://api.jenneranalytics.com/v1/run/r_019eb89df40c7a03880240a5bb6a4d10/datasets/trt_bounds?token=d4664b47d56343f8822ce99e4dad395a | diff --git a/jenner-check/t001_adsl/expected/log.txt b/jenner-check/t001_adsl/expected/log.txt new file mode 100644 index 0000000..b630c87 --- /dev/null +++ b/jenner-check/t001_adsl/expected/log.txt @@ -0,0 +1,49 @@ +Jenner 0.1.0 (Unlicensed - limited to 100 observations) +Get a license at https://jenneranalytics.com/license + +NOTE: Option OBS changed to 100. +NOTE: DATA dm + +NOTE: Processing inline DATALINES (5 lines) + +NOTE: Read 5 rows from DATALINES. +NOTE: Wrote dm (5 rows, 12 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA ex + +NOTE: Processing inline DATALINES (6 lines) + +NOTE: Read 6 rows from DATALINES. +NOTE: Wrote ex (6 rows, 6 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA ex1 + + +NOTE: Read 6 rows from ex. +NOTE: Wrote ex1 (6 rows, 10 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC SQL + +NOTE: Table trt_bounds created. +NOTE: PROC SQL statement used. +NOTE: PROC SQL + +NOTE: Table adsl_pre created. +NOTE: PROC SQL statement used. +NOTE: DATA adsl + + +NOTE: Read 5 rows from adsl_pre. +NOTE: Wrote adsl (5 rows, 19 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=adsl + +NOTE: PROC PRINT completed: 5 observations printed, 10 variables diff --git a/jenner-check/t001_adsl/expected/output.txt b/jenner-check/t001_adsl/expected/output.txt new file mode 100644 index 0000000..db940a1 --- /dev/null +++ b/jenner-check/t001_adsl/expected/output.txt @@ -0,0 +1,9 @@ + ADSL — Subject-Level Analysis Dataset + + USUBJID ARM SEX AGE COUNTRY TRTSDT TRTEDT SAFFL FASFL ITTFL +CDISCLAB-701-001 Placebo F 64 USA 2026-01-05 2026-02-02 Y Y Y +CDISCLAB-701-002 Drug 10mg M 58 USA 2026-01-06 2026-03-02 Y Y Y +CDISCLAB-701-003 Drug 20mg F 71 USA 2026-01-07 2026-02-04 Y Y Y +CDISCLAB-702-004 Drug 10mg M 49 JPN 2026-01-12 2026-02-09 Y Y Y +CDISCLAB-702-005 Placebo F 66 JPN 2026-01-13 2026-02-10 Y Y Y + diff --git a/jenner-check/t001_adsl/meta.json b/jenner-check/t001_adsl/meta.json new file mode 100644 index 0000000..b11e0e9 --- /dev/null +++ b/jenner-check/t001_adsl/meta.json @@ -0,0 +1,8 @@ +{ + "bundle": "t001_adsl", + "source_file": "01_adam_advanced/adsl/adsl.sas", + "source_blob_sha": "7ac02518ebc8bb327cd77ac37db13ae231640c1c", + "source_commit": "902e65c9402bcd23a5e2bdff0887ea309f06ebb9", + "tier": "real_data", + "notes": "Derives ADSL treatment bounds (PROC SQL min/max group-by + left join to DM) and population flags; mock DM(5)+EX(6) inline in place of XPT loads." +} \ No newline at end of file diff --git a/jenner-check/t001_adsl/script.sas b/jenner-check/t001_adsl/script.sas new file mode 100644 index 0000000..2722216 --- /dev/null +++ b/jenner-check/t001_adsl/script.sas @@ -0,0 +1,93 @@ +/***************************************************************************************** + Program: adsl.sas (Clinical Data Standards Lab — adapted for Jenner bundle) + Purpose: Create Subject-Level Analysis Dataset (ADSL) + Based on SDTM DM + EX + DM and EX are provided by the bundle autoexec (mock subjects) in place + of the upstream XPT loads; all derivation logic below is unchanged. + + Key Derivations: + - TRTSDT / TRTSDTM (first dose) + - TRTEDT / TRTEDTM (last dose) + - Population flags (simple SAFFL, FASFL, ITTFL) +******************************************************************************************/ + +/*---------------------------------------------------------------------------------------- + 2. Derive treatment bounds from EX +----------------------------------------------------------------------------------------*/ + +data ex1; + set ex; + + /* Start date/time */ + if not missing(exstdtc) then exstdt = input(substr(exstdtc,1,10), yymmdd10.); + if length(exstdtc) >= 16 then exstdtm = input(exstdtc, e8601dt.); + + /* End date/time */ + if not missing(exendtc) then exendt = input(substr(exendtc,1,10), yymmdd10.); + if length(exendtc) >= 16 then exendtm = input(exendtc, e8601dt.); + + format exstdt exendt yymmdd10. exstdtm exendtm e8601dt.; +run; + +proc sql; + create table trt_bounds as + select usubjid, + min(exstdt) as trtsdt format=yymmdd10., + min(exstdtm) as trtsdtm format=e8601dt., + max(coalesce(exendt, exstdt)) as trtedt format=yymmdd10., + max(coalesce(exendtm, exstdtm)) as trtedtm format=e8601dt. + from ex1 + group by usubjid; +quit; + +/*---------------------------------------------------------------------------------------- + 3. Merge DM with treatment bounds +----------------------------------------------------------------------------------------*/ + +proc sql; + create table adsl_pre as + select a.*, + b.trtsdt, b.trtsdtm, b.trtedt, b.trtedtm + from dm as a + left join trt_bounds as b + on a.usubjid = b.usubjid; +quit; + +/*---------------------------------------------------------------------------------------- + 4. Population flags (simple, illustrative) +----------------------------------------------------------------------------------------*/ + +data adsl; + set adsl_pre; + + length saffl fasfl ittfl $1; + + /* Example: everyone with non-missing TRTSDT is in all populations */ + if not missing(trtsdt) then do; + saffl = "Y"; + fasfl = "Y"; + ittfl = "Y"; + end; + else do; + saffl = "N"; + fasfl = "N"; + ittfl = "N"; + end; + + /* Keep core ADSL variables (extend as needed) */ + keep studyid usubjid subjid siteid + arm armcd + sex age ageu race ethnic + country + trtsdt trtsdtm trtedt trtedtm + saffl fasfl ittfl; +run; + +proc print data=adsl noobs; + var usubjid arm sex age country trtsdt trtedt saffl fasfl ittfl; + title "ADSL — Subject-Level Analysis Dataset"; +run; + +/***************************************************************************************** + End of Program +*****************************************************************************************/ diff --git a/jenner-check/t002_adint_vs/autoexec.sas b/jenner-check/t002_adint_vs/autoexec.sas new file mode 100644 index 0000000..ae52294 --- /dev/null +++ b/jenner-check/t002_adint_vs/autoexec.sas @@ -0,0 +1,58 @@ +options obs=100; + +/*-------------------------------------------------------------------------- + Bundle setup for adint_vs.sas + The upstream program loads SDTM VS, DM, EX and SV from XPT transport files + under "path/TDF_SDTM/". Here we stand up small, representative versions of + those four domains inline so the windowing / baseline logic runs as written. + Each subject has a screening (pre-dose) and two on-treatment SYSBP records, + with SV visit dates for the ±3-day windowing join. +--------------------------------------------------------------------------*/ + +data vs; + length usubjid $20 vstestcd $8 vstest $20 vsstresu $8 vsdtc $10; + infile datalines dlm='|' dsd truncover; + input usubjid $ vstestcd $ vstest $ vsstresn vsstresu $ vsdtc $; + datalines; +CDISCLAB-701-001|SYSBP|Systolic BP|128|mmHg|2026-01-02 +CDISCLAB-701-001|SYSBP|Systolic BP|132|mmHg|2026-01-15 +CDISCLAB-701-001|SYSBP|Systolic BP|126|mmHg|2026-01-29 +CDISCLAB-701-002|SYSBP|Systolic BP|140|mmHg|2026-01-03 +CDISCLAB-701-002|SYSBP|Systolic BP|135|mmHg|2026-01-16 +CDISCLAB-701-002|SYSBP|Systolic BP|130|mmHg|2026-01-30 +; +run; + +data dm; + length usubjid $20 arm $20 sex $1; + infile datalines dlm='|' dsd truncover; + input usubjid $ arm $ sex $ age; + datalines; +CDISCLAB-701-001|Placebo|F|64 +CDISCLAB-701-002|Drug 10mg|M|58 +; +run; + +data ex; + length usubjid $20 exstdtc $10; + infile datalines dlm='|' dsd truncover; + input usubjid $ exstdtc $; + datalines; +CDISCLAB-701-001|2026-01-05 +CDISCLAB-701-002|2026-01-06 +; +run; + +data sv; + length usubjid $20 visit $16 svstdtc $10; + infile datalines dlm='|' dsd truncover; + input usubjid $ visitnum visit $ svstdtc $; + datalines; +CDISCLAB-701-001|1|Screening|2026-01-02 +CDISCLAB-701-001|2|Week 2|2026-01-15 +CDISCLAB-701-001|3|Week 4|2026-01-29 +CDISCLAB-701-002|1|Screening|2026-01-03 +CDISCLAB-701-002|2|Week 2|2026-01-16 +CDISCLAB-701-002|3|Week 4|2026-01-30 +; +run; diff --git a/jenner-check/t002_adint_vs/expected.json b/jenner-check/t002_adint_vs/expected.json new file mode 100644 index 0000000..ca8844e --- /dev/null +++ b/jenner-check/t002_adint_vs/expected.json @@ -0,0 +1,19 @@ +{ + "_captured_at": "2026-06-11T21:39:24.944685Z", + "_captured_run_id": "r_019eb89fbe5071529ce6f351363a1f6c", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "NOTE: Table trt created.", + "NOTE: Wrote adint_vs (6 rows, 16 columns).", + "NOTE: PROC PRINT completed: 6 observations printed, 7 variables" + ], + "log_does_not_contain": [ + "ERROR:", + "[JENNER-ERROR" + ], + "diagnostics": { + "parse_warnings": [], + "runtime_warnings": [] + } +} \ No newline at end of file diff --git a/jenner-check/t002_adint_vs/expected/files.md b/jenner-check/t002_adint_vs/expected/files.md new file mode 100644 index 0000000..6be1156 --- /dev/null +++ b/jenner-check/t002_adint_vs/expected/files.md @@ -0,0 +1,30 @@ +These URLs are tied to a specific run and expire when that run is reaped; re-running the bundle regenerates them. + + +## Files + +| name | content_type | size_bytes | url | +|------|--------------|-----------:|-----| +| listing.txt | text/plain | 547 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/files/listing.txt?token=9b06c2dc30b240d08b7ef0c26bdd2248 | + +## Datasets + +| name | rows | columns | preview_url | +|------|-----:|--------:|-------------| +| adint_vs | 6 | 16 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/adint_vs?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| baseline | 6 | 16 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/baseline?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| baseline_final | 2 | 16 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/baseline_final?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| dm | 2 | 4 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/dm?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| ex | 2 | 2 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/ex?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| sv | 6 | 4 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/sv?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| sv2 | 6 | 4 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/sv2?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| trt | 2 | 2 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/trt?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| vs | 6 | 6 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/vs?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| vs1 | 6 | 10 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/vs1?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| vs2 | 6 | 12 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/vs2?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| vs3 | 6 | 13 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/vs3?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| vs4 | 6 | 15 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/vs4?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| vs5 | 6 | 16 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/vs5?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| vs6 | 6 | 17 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/vs6?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| vs7 | 6 | 18 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/vs7?token=9b06c2dc30b240d08b7ef0c26bdd2248 | +| vs8 | 6 | 19 | https://api.jenneranalytics.com/v1/run/r_019eb89fbe5071529ce6f351363a1f6c/datasets/vs8?token=9b06c2dc30b240d08b7ef0c26bdd2248 | diff --git a/jenner-check/t002_adint_vs/expected/log.txt b/jenner-check/t002_adint_vs/expected/log.txt new file mode 100644 index 0000000..4441c5a --- /dev/null +++ b/jenner-check/t002_adint_vs/expected/log.txt @@ -0,0 +1,139 @@ +Jenner 0.1.0 (Unlicensed - limited to 100 observations) +Get a license at https://jenneranalytics.com/license + +NOTE: Option OBS changed to 100. +NOTE: DATA vs + +NOTE: Processing inline DATALINES (6 lines) + +NOTE: Read 6 rows from DATALINES. +NOTE: Wrote vs (6 rows, 6 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA dm + +NOTE: Processing inline DATALINES (2 lines) + +NOTE: Read 2 rows from DATALINES. +NOTE: Wrote dm (2 rows, 4 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA ex + +NOTE: Processing inline DATALINES (2 lines) + +NOTE: Read 2 rows from DATALINES. +NOTE: Wrote ex (2 rows, 2 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA sv + +NOTE: Processing inline DATALINES (6 lines) + +NOTE: Read 6 rows from DATALINES. +NOTE: Wrote sv (6 rows, 4 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC SQL + +NOTE: Table trt created. +NOTE: PROC SQL statement used. +NOTE: PROC SQL + +NOTE: Table vs1 created. +NOTE: PROC SQL statement used. +NOTE: DATA vs2 + + +NOTE: Read 6 rows from vs1. +NOTE: Wrote vs2 (6 rows, 12 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA vs3 + + +NOTE: Read 6 rows from vs2. +NOTE: Wrote vs3 (6 rows, 13 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC SQL + +NOTE: Table sv2 created. +NOTE: PROC SQL statement used. +NOTE: PROC SQL + +NOTE: Table vs4 created. +NOTE: PROC SQL statement used. +NOTE: PROC SORT data=vs4 + +NOTE: Unlicensed mode - input limited to 100 observations. +NOTE: Read 6 rows from vs4. +NOTE: Wrote vs4 (6 rows, 15 columns). +NOTE: PROC SORT statement used. +NOTE: DATA vs5 + + +NOTE: Read 6 rows from vs4. +NOTE: Wrote vs5 (6 rows, 16 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA baseline + + +NOTE: Read 6 rows from vs5. +NOTE: Wrote baseline (6 rows, 16 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC SORT data=baseline + +NOTE: Unlicensed mode - input limited to 100 observations. +NOTE: Read 6 rows from baseline. +NOTE: Wrote baseline (6 rows, 16 columns). +NOTE: PROC SORT statement used. +NOTE: DATA baseline_final + + +NOTE: Read 6 rows from baseline. +NOTE: Wrote baseline_final (2 rows, 16 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC SQL + +NOTE: Table vs6 created. +NOTE: PROC SQL statement used. +NOTE: DATA vs7 + + +NOTE: Read 6 rows from vs6. +NOTE: Wrote vs7 (6 rows, 18 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA vs8 + + +NOTE: Read 6 rows from vs7. +NOTE: Wrote vs8 (6 rows, 19 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA adint_vs + + +NOTE: Read 6 rows from vs8. +NOTE: Wrote adint_vs (6 rows, 16 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=adint_vs + +NOTE: PROC PRINT completed: 6 observations printed, 7 variables diff --git a/jenner-check/t002_adint_vs/expected/output.txt b/jenner-check/t002_adint_vs/expected/output.txt new file mode 100644 index 0000000..44c753c --- /dev/null +++ b/jenner-check/t002_adint_vs/expected/output.txt @@ -0,0 +1,10 @@ + ADINT_VS — Intermediate Vital Signs (windowing + baseline) + + USUBJID VSTESTCD VSSTRESN ADT ADY AVISIT ABLFL +CDISCLAB-701-001 SYSBP 126 2026-01-29 25 Week 4 Y +CDISCLAB-701-002 SYSBP 130 2026-01-30 25 Week 4 Y +CDISCLAB-701-001 SYSBP 128 2026-01-02 -3 Screening +CDISCLAB-701-001 SYSBP 132 2026-01-15 11 Week 2 +CDISCLAB-701-002 SYSBP 140 2026-01-03 -3 Screening +CDISCLAB-701-002 SYSBP 135 2026-01-16 11 Week 2 + diff --git a/jenner-check/t002_adint_vs/meta.json b/jenner-check/t002_adint_vs/meta.json new file mode 100644 index 0000000..32bf019 --- /dev/null +++ b/jenner-check/t002_adint_vs/meta.json @@ -0,0 +1,8 @@ +{ + "bundle": "t002_adint_vs", + "source_file": "01_adam_advanced/adint/adint_vs.sas", + "source_blob_sha": "8134eb8de0e2f3888fc6fba7c563d8731e94bff8", + "source_commit": "902e65c9402bcd23a5e2bdff0887ea309f06ebb9", + "tier": "real_data", + "notes": "Intermediate ADaM VS: SV-based visit windowing (+/-3d SQL join), ADY study-day, by-group ABLFL with retain; mock VS/DM/EX/SV inline in place of XPT loads." +} \ No newline at end of file diff --git a/jenner-check/t002_adint_vs/script.sas b/jenner-check/t002_adint_vs/script.sas new file mode 100644 index 0000000..2dd7a87 --- /dev/null +++ b/jenner-check/t002_adint_vs/script.sas @@ -0,0 +1,173 @@ +/***************************************************************************************** + Program: adint_vs.sas (Clinical Data Standards Lab — adapted for Jenner bundle) + Purpose: Create Intermediate ADaM Vital Signs Dataset (ADINT_VS) + Based on SDTM VS / DM / EX / SV (provided by the bundle autoexec in + place of the upstream XPT loads); all derivation logic is unchanged. + + Key Derivations: + - ADT / ADTM from VSDTC + - ADY (study day) from TRTSDT + - AVISIT / AVISITN via SV windowing (+/- 3 days) + - Baseline flag (ABLFL) + - Imputation flag for ADY +******************************************************************************************/ + +/*---------------------------------------------------------------------------------------- + 2. Derive Treatment Start Date (TRTSDT) from EX +----------------------------------------------------------------------------------------*/ + +proc sql; + create table trt as + select usubjid, + min(input(exstdtc, yymmdd10.)) as trtsdt format=yymmdd10. + from ex + group by usubjid; +quit; + +/*---------------------------------------------------------------------------------------- + 3. Merge VS with TRTSDT and DM +----------------------------------------------------------------------------------------*/ + +proc sql; + create table vs1 as + select a.*, b.trtsdt, c.arm, c.sex, c.age + from vs as a + left join trt as b on a.usubjid = b.usubjid + left join dm as c on a.usubjid = c.usubjid; +quit; + +/*---------------------------------------------------------------------------------------- + 4. Convert VSDTC -> ADT / ADTM +----------------------------------------------------------------------------------------*/ + +data vs2; + set vs1; + + if not missing(vsdtc) then adt = input(substr(vsdtc,1,10), yymmdd10.); + if length(vsdtc) >= 16 then adtm = input(vsdtc, e8601dt.); + + format adt yymmdd10. adtm e8601dt.; +run; + +/*---------------------------------------------------------------------------------------- + 5. Derive Study Day (ADY) +----------------------------------------------------------------------------------------*/ + +data vs3; + set vs2; + if not missing(adt) and not missing(trtsdt) then + ady = adt - trtsdt + (adt >= trtsdt); +run; + +/*---------------------------------------------------------------------------------------- + 6. Visit Windowing (AVISIT / AVISITN) using SV +----------------------------------------------------------------------------------------*/ + +proc sql; + create table sv2 as + select usubjid, + visitnum, + visit, + input(svstdtc, yymmdd10.) as svstdt + from sv; +quit; + +proc sql; + create table vs4 as + select a.*, + b.visit as avisit, + b.visitnum as avisitn + from vs3 as a + left join sv2 as b + on a.usubjid = b.usubjid + and abs(a.adt - b.svstdt) <= 3 + order by usubjid, vstestcd, adt; +quit; + +/*---------------------------------------------------------------------------------------- + 7. Baseline Identification (ABLFL) + Baseline = last non-missing value prior to TRTSDT per test +----------------------------------------------------------------------------------------*/ + +proc sort data=vs4; by usubjid vstestcd adt; run; + +data vs5; + set vs4; + by usubjid vstestcd; + + if first.vstestcd then ablfl_temp = ""; + + if not missing(adt) and adt < trtsdt then ablfl_temp = "Y"; + + retain ablfl_temp; +run; + +data baseline; + set vs5; + if ablfl_temp = "Y"; +run; + +proc sort data=baseline; + by usubjid vstestcd descending adt; +run; + +data baseline_final; + set baseline; + by usubjid vstestcd; + if first.vstestcd; +run; + +proc sql; + create table vs6 as + select a.*, + case when a.usubjid=b.usubjid + and a.vstestcd=b.vstestcd + and a.adt=b.adt + then "Y" else "" end as ablfl + from vs5 as a + left join baseline_final as b + on a.usubjid=b.usubjid + and a.vstestcd=b.vstestcd + and a.adt=b.adt; +quit; + +/*---------------------------------------------------------------------------------------- + 8. Imputation Flags +----------------------------------------------------------------------------------------*/ + +data vs7; + set vs6; + if missing(ady) and not missing(adt) then impute_ady = "Y"; +run; + +/*---------------------------------------------------------------------------------------- + 9. Unit Harmonization Placeholder +----------------------------------------------------------------------------------------*/ + +data vs8; + set vs7; + + /* Example: Systolic BP (SYSBP) in mmHg */ + if vstestcd = "SYSBP" and vsstresu = "mmHg" then aval = vsstresn; +run; + +/*---------------------------------------------------------------------------------------- + 10. Final ADINT_VS Dataset +----------------------------------------------------------------------------------------*/ + +data adint_vs; + set vs8; + + keep usubjid vstestcd vstest vsstresn vsstresu + adt adtm ady avisit avisitn + ablfl impute_ady trtsdt arm sex age; +run; + +proc print data=adint_vs noobs; + var usubjid vstestcd vsstresn adt ady avisit ablfl; + title "ADINT_VS — Intermediate Vital Signs (windowing + baseline)"; +run; + +/***************************************************************************************** + End of Program +*****************************************************************************************/ diff --git a/jenner-check/t003_advs/autoexec.sas b/jenner-check/t003_advs/autoexec.sas new file mode 100644 index 0000000..8748c0b --- /dev/null +++ b/jenner-check/t003_advs/autoexec.sas @@ -0,0 +1,39 @@ +options obs=100; + +/*-------------------------------------------------------------------------- + Bundle setup for advs.sas + The upstream program reads ADINT_VS and ADSL from a permanent ADaM library + ("path/adam"). Here we provide both as inline WORK tables matching the + columns the program consumes, so the BDS derivations (PARAM/PARAMCD, + AVAL/AVALC, BASE, CHG, PCHG) run as written. Each subject has one baseline + (ABLFL="Y") and two post-baseline SYSBP records so change-from-baseline is + exercised. +--------------------------------------------------------------------------*/ + +data adint_vs; + length usubjid $20 vstestcd $8 vstest $20 vsstresu $8 vsstresc $8 + ablfl $1 avisit $16 impute_ady $1; + infile datalines dlm='|' dsd truncover; + input usubjid $ vstestcd $ vstest $ vsstresn vsstresu $ vsstresc $ + ablfl $ adt ady avisit $ avisitn impute_ady $; + datalines; +CDISCLAB-701-001|SYSBP|Systolic BP|130|mmHg|130|Y|24474|-3|Screening|1| +CDISCLAB-701-001|SYSBP|Systolic BP|124|mmHg|124||24487|11|Week 2|2| +CDISCLAB-701-001|SYSBP|Systolic BP|118|mmHg|118||24501|25|Week 4|3| +CDISCLAB-701-002|SYSBP|Systolic BP|142|mmHg|142|Y|24475|-3|Screening|1| +CDISCLAB-701-002|SYSBP|Systolic BP|138|mmHg|138||24488|11|Week 2|2| +CDISCLAB-701-002|SYSBP|Systolic BP|150|mmHg|150||24502|25|Week 4|3| +; +run; + +data adsl; + length studyid $12 usubjid $20 subjid $8 siteid $4 arm $20 armcd $8 + saffl fasfl ittfl $1; + infile datalines dlm='|' dsd truncover; + input studyid $ usubjid $ subjid $ siteid $ arm $ armcd $ + saffl $ fasfl $ ittfl $; + datalines; +CDISCLAB|CDISCLAB-701-001|001|0701|Placebo|PBO|Y|Y|Y +CDISCLAB|CDISCLAB-701-002|002|0701|Drug 10mg|D10|Y|Y|Y +; +run; diff --git a/jenner-check/t003_advs/expected.json b/jenner-check/t003_advs/expected.json new file mode 100644 index 0000000..fd4b9c9 --- /dev/null +++ b/jenner-check/t003_advs/expected.json @@ -0,0 +1,18 @@ +{ + "_captured_at": "2026-06-11T21:40:41.596955Z", + "_captured_run_id": "r_019eb8a115b67f6187195151ad191fc7", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "NOTE: Wrote advs (6 rows, 26 columns).", + "NOTE: PROC PRINT completed: 6 observations printed, 8 variables" + ], + "log_does_not_contain": [ + "ERROR:", + "[JENNER-ERROR" + ], + "diagnostics": { + "parse_warnings": [], + "runtime_warnings": [] + } +} \ No newline at end of file diff --git a/jenner-check/t003_advs/expected/files.md b/jenner-check/t003_advs/expected/files.md new file mode 100644 index 0000000..97d8cf2 --- /dev/null +++ b/jenner-check/t003_advs/expected/files.md @@ -0,0 +1,23 @@ +These URLs are tied to a specific run and expire when that run is reaped; re-running the bundle regenerates them. + + +## Files + +| name | content_type | size_bytes | url | +|------|--------------|-----------:|-----| +| listing.txt | text/plain | 597 | https://api.jenneranalytics.com/v1/run/r_019eb8a115b67f6187195151ad191fc7/files/listing.txt?token=dc8786f02bab4cb98d21db580280bd89 | + +## Datasets + +| name | rows | columns | preview_url | +|------|-----:|--------:|-------------| +| adint_vs | 6 | 12 | https://api.jenneranalytics.com/v1/run/r_019eb8a115b67f6187195151ad191fc7/datasets/adint_vs?token=dc8786f02bab4cb98d21db580280bd89 | +| adsl | 2 | 9 | https://api.jenneranalytics.com/v1/run/r_019eb8a115b67f6187195151ad191fc7/datasets/adsl?token=dc8786f02bab4cb98d21db580280bd89 | +| advs | 6 | 26 | https://api.jenneranalytics.com/v1/run/r_019eb8a115b67f6187195151ad191fc7/datasets/advs?token=dc8786f02bab4cb98d21db580280bd89 | +| base | 2 | 3 | https://api.jenneranalytics.com/v1/run/r_019eb8a115b67f6187195151ad191fc7/datasets/base?token=dc8786f02bab4cb98d21db580280bd89 | +| vs1 | 6 | 20 | https://api.jenneranalytics.com/v1/run/r_019eb8a115b67f6187195151ad191fc7/datasets/vs1?token=dc8786f02bab4cb98d21db580280bd89 | +| vs2 | 6 | 22 | https://api.jenneranalytics.com/v1/run/r_019eb8a115b67f6187195151ad191fc7/datasets/vs2?token=dc8786f02bab4cb98d21db580280bd89 | +| vs3 | 6 | 24 | https://api.jenneranalytics.com/v1/run/r_019eb8a115b67f6187195151ad191fc7/datasets/vs3?token=dc8786f02bab4cb98d21db580280bd89 | +| vs4 | 6 | 25 | https://api.jenneranalytics.com/v1/run/r_019eb8a115b67f6187195151ad191fc7/datasets/vs4?token=dc8786f02bab4cb98d21db580280bd89 | +| vs5 | 6 | 27 | https://api.jenneranalytics.com/v1/run/r_019eb8a115b67f6187195151ad191fc7/datasets/vs5?token=dc8786f02bab4cb98d21db580280bd89 | +| vs6 | 6 | 28 | https://api.jenneranalytics.com/v1/run/r_019eb8a115b67f6187195151ad191fc7/datasets/vs6?token=dc8786f02bab4cb98d21db580280bd89 | diff --git a/jenner-check/t003_advs/expected/log.txt b/jenner-check/t003_advs/expected/log.txt new file mode 100644 index 0000000..e94e7a3 --- /dev/null +++ b/jenner-check/t003_advs/expected/log.txt @@ -0,0 +1,77 @@ +Jenner 0.1.0 (Unlicensed - limited to 100 observations) +Get a license at https://jenneranalytics.com/license + +NOTE: Option OBS changed to 100. +NOTE: DATA adint_vs + +NOTE: Processing inline DATALINES (6 lines) + +NOTE: Read 6 rows from DATALINES. +NOTE: Wrote adint_vs (6 rows, 12 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA adsl + +NOTE: Processing inline DATALINES (2 lines) + +NOTE: Read 2 rows from DATALINES. +NOTE: Wrote adsl (2 rows, 9 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC SQL + +NOTE: Table vs1 created. +NOTE: PROC SQL statement used. +NOTE: DATA vs2 + + +NOTE: Read 6 rows from vs1. +NOTE: Wrote vs2 (6 rows, 22 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA vs3 + + +NOTE: Read 6 rows from vs2. +NOTE: Wrote vs3 (6 rows, 24 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC SQL + +NOTE: Table base created. +NOTE: PROC SQL statement used. +NOTE: PROC SQL + +NOTE: Table vs4 created. +NOTE: PROC SQL statement used. +NOTE: DATA vs5 + + +NOTE: Read 6 rows from vs4. +NOTE: Wrote vs5 (6 rows, 27 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA vs6 + + +NOTE: Read 6 rows from vs5. +NOTE: Wrote vs6 (6 rows, 28 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA advs + + +NOTE: Read 6 rows from vs6. +NOTE: Wrote advs (6 rows, 26 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=advs + +NOTE: PROC PRINT completed: 6 observations printed, 8 variables diff --git a/jenner-check/t003_advs/expected/output.txt b/jenner-check/t003_advs/expected/output.txt new file mode 100644 index 0000000..4605c87 --- /dev/null +++ b/jenner-check/t003_advs/expected/output.txt @@ -0,0 +1,10 @@ + ADVS — Final Vital Signs BDS (change from baseline) + + USUBJID PARAMCD AVISIT AVAL BASE CHG PCHG SRC +CDISCLAB-701-001 SYSBP Screening 130 130 0 0 VS +CDISCLAB-701-001 SYSBP Week 2 124 130 -6 -4.6153846154 VS +CDISCLAB-701-001 SYSBP Week 4 118 130 -12 -9.2307692308 VS +CDISCLAB-701-002 SYSBP Screening 142 142 0 0 VS +CDISCLAB-701-002 SYSBP Week 2 138 142 -4 -2.8169014085 VS +CDISCLAB-701-002 SYSBP Week 4 150 142 8 5.6338028169 VS + diff --git a/jenner-check/t003_advs/meta.json b/jenner-check/t003_advs/meta.json new file mode 100644 index 0000000..688273a --- /dev/null +++ b/jenner-check/t003_advs/meta.json @@ -0,0 +1,8 @@ +{ + "bundle": "t003_advs", + "source_file": "01_adam_advanced/bds/advs.sas", + "source_blob_sha": "0a7234d8d062d01c5c231a94d03be7abfafe2236", + "source_commit": "902e65c9402bcd23a5e2bdff0887ea309f06ebb9", + "tier": "real_data", + "notes": "Final ADaM VS (BDS): PARAM/PARAMCD, AVAL/AVALC, PROC SQL baseline broadcast, CHG/PCHG change-from-baseline; mock ADINT_VS+ADSL inline; final write redirected to WORK." +} \ No newline at end of file diff --git a/jenner-check/t003_advs/script.sas b/jenner-check/t003_advs/script.sas new file mode 100644 index 0000000..ebfbbfd --- /dev/null +++ b/jenner-check/t003_advs/script.sas @@ -0,0 +1,121 @@ +/***************************************************************************************** + Program: advs.sas (Clinical Data Standards Lab — adapted for Jenner bundle) + Purpose: Create Final ADaM Vital Signs Dataset (ADVS) + Based on ADINT_VS + ADSL (provided by the bundle autoexec as WORK + tables in place of the upstream permanent ADaM library); all + derivation logic is unchanged. The final dataset is written to WORK + instead of the permanent "adam" library. + + Key Derivations: + - PARAM / PARAMCD + - AVAL / AVALC + - BASE / CHG / PCHG + - Traceability (SRC = "VS") +******************************************************************************************/ + +/*---------------------------------------------------------------------------------------- + 2. Merge ADINT_VS with ADSL +----------------------------------------------------------------------------------------*/ + +proc sql; + create table vs1 as + select a.*, + b.studyid, b.subjid, b.siteid, + b.arm, b.armcd, + b.saffl, b.fasfl, b.ittfl + from adint_vs as a + left join adsl as b + on a.usubjid = b.usubjid; +quit; + +/*---------------------------------------------------------------------------------------- + 3. PARAM / PARAMCD +----------------------------------------------------------------------------------------*/ + +data vs2; + set vs1; + + length param $200 paramcd $20; + + param = strip(vstest) || " (" || strip(vsstresu) || ")"; + paramcd = strip(vstestcd); +run; + +/*---------------------------------------------------------------------------------------- + 4. AVAL / AVALC +----------------------------------------------------------------------------------------*/ + +data vs3; + set vs2; + + aval = vsstresn; + avalc = vsstresc; +run; + +/*---------------------------------------------------------------------------------------- + 5. Baseline Value (BASE) +----------------------------------------------------------------------------------------*/ + +proc sql; + create table base as + select usubjid, paramcd, aval as base + from vs3 + where ablfl = "Y"; +quit; + +proc sql; + create table vs4 as + select a.*, b.base + from vs3 as a + left join base as b + on a.usubjid = b.usubjid + and a.paramcd = b.paramcd; +quit; + +/*---------------------------------------------------------------------------------------- + 6. Change from Baseline (CHG) and Percent Change (PCHG) +----------------------------------------------------------------------------------------*/ + +data vs5; + set vs4; + + if not missing(aval) and not missing(base) then chg = aval - base; + if not missing(aval) and not missing(base) and base ne 0 then + pchg = ((aval - base) / base) * 100; +run; + +/*---------------------------------------------------------------------------------------- + 7. Traceability +----------------------------------------------------------------------------------------*/ + +data vs6; + set vs5; + src = "VS"; +run; + +/*---------------------------------------------------------------------------------------- + 8. Final ADVS Dataset +----------------------------------------------------------------------------------------*/ + +data advs; + set vs6; + + keep studyid usubjid subjid siteid + arm armcd saffl fasfl ittfl + param paramcd + aval avalc base chg pchg + adt ady + avisit avisitn + ablfl impute_ady + vsstresn vsstresu vsstresc + src; +run; + +proc print data=advs noobs; + var usubjid paramcd avisit aval base chg pchg src; + title "ADVS — Final Vital Signs BDS (change from baseline)"; +run; + +/***************************************************************************************** + End of Program +******************************************************************************************/ diff --git a/jenner-check/t004_adint_ex/autoexec.sas b/jenner-check/t004_adint_ex/autoexec.sas new file mode 100644 index 0000000..2c519c5 --- /dev/null +++ b/jenner-check/t004_adint_ex/autoexec.sas @@ -0,0 +1,35 @@ +options obs=100; + +/*-------------------------------------------------------------------------- + Bundle setup for adint_ex.sas + The upstream program loads SDTM EX and DM from XPT transport files under + "path/TDF_SDTM/". Here we provide both inline so the cumulative-dose + accumulation (RETAIN + first.EXTRT) and the on-treatment / first-dose / + last-dose flag logic run as written. Each subject has multiple EX records + per treatment so the cumulative dose accumulates across visits. +--------------------------------------------------------------------------*/ + +data ex; + length usubjid $20 extrt $20 exdosu $8 exstdtc $19 exendtc $19; + infile datalines dlm='|' dsd truncover; + input usubjid $ exseq extrt $ exdose exdosu $ exstdtc $ exendtc $; + datalines; +CDISCLAB-701-002|1|Drug|10|mg|2026-01-06|2026-01-20 +CDISCLAB-701-002|2|Drug|10|mg|2026-01-20|2026-02-03 +CDISCLAB-701-002|3|Drug|10|mg|2026-02-03|2026-02-17 +CDISCLAB-701-003|1|Drug|20|mg|2026-01-07|2026-01-21 +CDISCLAB-701-003|2|Drug|20|mg|2026-01-21|2026-02-04 +CDISCLAB-702-004|1|Drug|10|mg|2026-01-12|2026-01-26 +; +run; + +data dm; + length usubjid $20 arm $20 sex $1; + infile datalines dlm='|' dsd truncover; + input usubjid $ arm $ sex $ age; + datalines; +CDISCLAB-701-002|Drug 10mg|M|58 +CDISCLAB-701-003|Drug 20mg|F|71 +CDISCLAB-702-004|Drug 10mg|M|49 +; +run; diff --git a/jenner-check/t004_adint_ex/expected.json b/jenner-check/t004_adint_ex/expected.json new file mode 100644 index 0000000..3e79e6d --- /dev/null +++ b/jenner-check/t004_adint_ex/expected.json @@ -0,0 +1,19 @@ +{ + "_captured_at": "2026-06-11T21:41:55.181213Z", + "_captured_run_id": "r_019eb8a234c97f628b91bf277a12de99", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "NOTE: Table trt_bounds created.", + "NOTE: Wrote adint_ex (6 rows, 22 columns).", + "NOTE: PROC PRINT completed: 6 observations printed, 10 variables" + ], + "log_does_not_contain": [ + "ERROR:", + "[JENNER-ERROR" + ], + "diagnostics": { + "parse_warnings": [], + "runtime_warnings": [] + } +} \ No newline at end of file diff --git a/jenner-check/t004_adint_ex/expected/files.md b/jenner-check/t004_adint_ex/expected/files.md new file mode 100644 index 0000000..57dad4b --- /dev/null +++ b/jenner-check/t004_adint_ex/expected/files.md @@ -0,0 +1,22 @@ +These URLs are tied to a specific run and expire when that run is reaped; re-running the bundle regenerates them. + + +## Files + +| name | content_type | size_bytes | url | +|------|--------------|-----------:|-----| +| listing.txt | text/plain | 676 | https://api.jenneranalytics.com/v1/run/r_019eb8a234c97f628b91bf277a12de99/files/listing.txt?token=9180965695b6430d998daa2a940e9b12 | + +## Datasets + +| name | rows | columns | preview_url | +|------|-----:|--------:|-------------| +| adint_ex | 6 | 22 | https://api.jenneranalytics.com/v1/run/r_019eb8a234c97f628b91bf277a12de99/datasets/adint_ex?token=9180965695b6430d998daa2a940e9b12 | +| dm | 3 | 4 | https://api.jenneranalytics.com/v1/run/r_019eb8a234c97f628b91bf277a12de99/datasets/dm?token=9180965695b6430d998daa2a940e9b12 | +| ex | 6 | 7 | https://api.jenneranalytics.com/v1/run/r_019eb8a234c97f628b91bf277a12de99/datasets/ex?token=9180965695b6430d998daa2a940e9b12 | +| ex1 | 6 | 11 | https://api.jenneranalytics.com/v1/run/r_019eb8a234c97f628b91bf277a12de99/datasets/ex1?token=9180965695b6430d998daa2a940e9b12 | +| ex2 | 6 | 18 | https://api.jenneranalytics.com/v1/run/r_019eb8a234c97f628b91bf277a12de99/datasets/ex2?token=9180965695b6430d998daa2a940e9b12 | +| ex3 | 6 | 19 | https://api.jenneranalytics.com/v1/run/r_019eb8a234c97f628b91bf277a12de99/datasets/ex3?token=9180965695b6430d998daa2a940e9b12 | +| ex4 | 6 | 21 | https://api.jenneranalytics.com/v1/run/r_019eb8a234c97f628b91bf277a12de99/datasets/ex4?token=9180965695b6430d998daa2a940e9b12 | +| ex5 | 6 | 24 | https://api.jenneranalytics.com/v1/run/r_019eb8a234c97f628b91bf277a12de99/datasets/ex5?token=9180965695b6430d998daa2a940e9b12 | +| trt_bounds | 3 | 5 | https://api.jenneranalytics.com/v1/run/r_019eb8a234c97f628b91bf277a12de99/datasets/trt_bounds?token=9180965695b6430d998daa2a940e9b12 | diff --git a/jenner-check/t004_adint_ex/expected/log.txt b/jenner-check/t004_adint_ex/expected/log.txt new file mode 100644 index 0000000..e92ad44 --- /dev/null +++ b/jenner-check/t004_adint_ex/expected/log.txt @@ -0,0 +1,79 @@ +Jenner 0.1.0 (Unlicensed - limited to 100 observations) +Get a license at https://jenneranalytics.com/license + +NOTE: Option OBS changed to 100. +NOTE: DATA ex + +NOTE: Processing inline DATALINES (6 lines) + +NOTE: Read 6 rows from DATALINES. +NOTE: Wrote ex (6 rows, 7 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA dm + +NOTE: Processing inline DATALINES (3 lines) + +NOTE: Read 3 rows from DATALINES. +NOTE: Wrote dm (3 rows, 4 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA ex1 + + +NOTE: Read 6 rows from ex. +NOTE: Wrote ex1 (6 rows, 11 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC SQL + +NOTE: Table trt_bounds created. +NOTE: PROC SQL statement used. +NOTE: PROC SQL + +NOTE: Table ex2 created. +NOTE: PROC SQL statement used. +NOTE: DATA ex3 + + +NOTE: Read 6 rows from ex2. +NOTE: Wrote ex3 (6 rows, 19 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC SORT data=ex3 + +NOTE: Unlicensed mode - input limited to 100 observations. +NOTE: Read 6 rows from ex3. +NOTE: Wrote ex3 (6 rows, 19 columns). +NOTE: PROC SORT statement used. +NOTE: DATA ex4 + + +NOTE: Read 6 rows from ex3. +NOTE: Wrote ex4 (6 rows, 21 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA ex5 + + +NOTE: Read 6 rows from ex4. +NOTE: Wrote ex5 (6 rows, 24 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA adint_ex + + +NOTE: Read 6 rows from ex5. +NOTE: Wrote adint_ex (6 rows, 22 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=adint_ex + +NOTE: PROC PRINT completed: 6 observations printed, 10 variables diff --git a/jenner-check/t004_adint_ex/expected/output.txt b/jenner-check/t004_adint_ex/expected/output.txt new file mode 100644 index 0000000..d3be376 --- /dev/null +++ b/jenner-check/t004_adint_ex/expected/output.txt @@ -0,0 +1,10 @@ + ADINT_EX — Intermediate Exposure (cumulative dose + flags) + + USUBJID EXSEQ EXTRT EXDOSE ADT ADY CUMDOSE ONTRTFL FIRSTDOSEFL LASTDOSEFL +CDISCLAB-701-002 1 Drug 10 2026-01-06 1 10 Y Y +CDISCLAB-701-002 2 Drug 10 2026-01-20 15 20 Y +CDISCLAB-701-002 3 Drug 10 2026-02-03 29 30 Y Y +CDISCLAB-701-003 1 Drug 20 2026-01-07 1 20 Y Y +CDISCLAB-701-003 2 Drug 20 2026-01-21 15 40 Y Y +CDISCLAB-702-004 1 Drug 10 2026-01-12 1 10 Y Y Y + diff --git a/jenner-check/t004_adint_ex/meta.json b/jenner-check/t004_adint_ex/meta.json new file mode 100644 index 0000000..1012301 --- /dev/null +++ b/jenner-check/t004_adint_ex/meta.json @@ -0,0 +1,8 @@ +{ + "bundle": "t004_adint_ex", + "source_file": "01_adam_advanced/adint/adint_ex.sas", + "source_blob_sha": "c8a30fa3085ca771a8781a1c690ddf1be05262e0", + "source_commit": "902e65c9402bcd23a5e2bdff0887ea309f06ebb9", + "tier": "real_data", + "notes": "Intermediate ADaM EX: TRTSDT/TRTEDT bounds (PROC SQL), per-record ADY, cumulative dose via RETAIN + first.EXTRT, on-treatment/first-dose/last-dose flags; mock EX(6)+DM(3) inline in place of XPT loads." +} \ No newline at end of file diff --git a/jenner-check/t004_adint_ex/script.sas b/jenner-check/t004_adint_ex/script.sas new file mode 100644 index 0000000..e325a6a --- /dev/null +++ b/jenner-check/t004_adint_ex/script.sas @@ -0,0 +1,137 @@ +/***************************************************************************************** + Program: adint_ex.sas (Clinical Data Standards Lab — adapted for Jenner bundle) + Purpose: Create Intermediate ADaM Exposure Dataset (ADINT_EX) + Based on SDTM EX + DM (provided by the bundle autoexec in place of the + upstream XPT loads); all derivation logic is unchanged. + + Key Derivations: + - TRTSDT / TRTSDTM (first dose) + - TRTEDT / TRTEDTM (last dose) + - ADT / ADTM / ADY per EX record + - Cumulative dose (per subject, per treatment) via RETAIN + - Exposure flags (on-treatment, first dose, last dose) +******************************************************************************************/ + +/*---------------------------------------------------------------------------------------- + 2. Convert EX dates/times -> ADT / ADTM +----------------------------------------------------------------------------------------*/ + +data ex1; + set ex; + + /* Start date/time */ + if not missing(exstdtc) then adt = input(substr(exstdtc,1,10), yymmdd10.); + if length(exstdtc) >= 16 then adtm = input(exstdtc, e8601dt.); + + /* End date/time */ + if not missing(exendtc) then aenddt = input(substr(exendtc,1,10), yymmdd10.); + if length(exendtc) >= 16 then aendtm = input(exendtc, e8601dt.); + + format adt aenddt yymmdd10. adtm aendtm e8601dt.; +run; + +/*---------------------------------------------------------------------------------------- + 3. Derive TRTSDT / TRTSDTM and TRTEDT / TRTEDTM per subject +----------------------------------------------------------------------------------------*/ + +proc sql; + create table trt_bounds as + select usubjid, + min(adt) as trtsdt format=yymmdd10., + min(adtm) as trtsdtm format=e8601dt., + max(coalesce(aenddt, adt)) as trtedt format=yymmdd10., + max(coalesce(aendtm, adtm)) as trtedtm format=e8601dt. + from ex1 + group by usubjid; +quit; + +/*---------------------------------------------------------------------------------------- + 4. Merge with DM and TRT bounds +----------------------------------------------------------------------------------------*/ + +proc sql; + create table ex2 as + select a.*, + b.trtsdt, b.trtsdtm, b.trtedt, b.trtedtm, + c.arm, c.sex, c.age + from ex1 as a + left join trt_bounds as b on a.usubjid = b.usubjid + left join dm as c on a.usubjid = c.usubjid; +quit; + +/*---------------------------------------------------------------------------------------- + 5. Derive Study Day (ADY) for each exposure record +----------------------------------------------------------------------------------------*/ + +data ex3; + set ex2; + if not missing(adt) and not missing(trtsdt) then + ady = adt - trtsdt + (adt >= trtsdt); +run; + +/*---------------------------------------------------------------------------------------- + 6. Cumulative Dose per Subject / Treatment +----------------------------------------------------------------------------------------*/ + +proc sort data=ex3; + by usubjid extrt adt adtm; +run; + +data ex4; + set ex3; + by usubjid extrt; + + length doseu_std $20; + doseu_std = upcase(exdosu); + + /* Example: assume all doses are in same unit per treatment */ + if first.extrt then cumdose = 0; + cumdose + exdose; + + retain cumdose; +run; + +/*---------------------------------------------------------------------------------------- + 7. Exposure Flags +----------------------------------------------------------------------------------------*/ + +data ex5; + set ex4; + + /* On-treatment flag: within TRTSDT-TRTEDT */ + if not missing(adt) and not missing(trtsdt) and not missing(trtedt) then do; + if trtsdt <= adt <= trtedt then ontrtfl = "Y"; + else ontrtfl = "N"; + end; + + /* First dose flag */ + if adt = trtsdt and adtm = trtsdtm then firstdosefl = "Y"; + else firstdosefl = ""; + + /* Last dose flag */ + if coalesce(aenddt, adt) = trtedt and coalesce(aendtm, adtm) = trtedtm then lastdosefl = "Y"; + else lastdosefl = ""; +run; + +/*---------------------------------------------------------------------------------------- + 8. Final ADINT_EX Dataset +----------------------------------------------------------------------------------------*/ + +data adint_ex; + set ex5; + + keep usubjid exseq extrt exdose exdosu doseu_std + adt adtm aenddt aendtm ady + trtsdt trtsdtm trtedt trtedtm + cumdose ontrtfl firstdosefl lastdosefl + arm sex age; +run; + +proc print data=adint_ex noobs; + var usubjid exseq extrt exdose adt ady cumdose ontrtfl firstdosefl lastdosefl; + title "ADINT_EX — Intermediate Exposure (cumulative dose + flags)"; +run; + +/***************************************************************************************** + End of Program +*****************************************************************************************/