diff --git a/jenner-check/.gitignore b/jenner-check/.gitignore new file mode 100644 index 0000000..75fb4a3 --- /dev/null +++ b/jenner-check/.gitignore @@ -0,0 +1,2 @@ +*_response.json +response.json diff --git a/jenner-check/README.md b/jenner-check/README.md new file mode 100644 index 0000000..58459d6 --- /dev/null +++ b/jenner-check/README.md @@ -0,0 +1,76 @@ +# jenner-check: compatibility test bundles + +Each `tNNN_/` subdirectory is a self-contained test bundle built from SAS +code that already lives in this repository. A bundle pins the output of a +captured passing run so the same script can be re-run later and compared +against that snapshot. + +This directory is fully self-contained: nothing outside `jenner-check/` is +referenced or modified, nothing runs on merge or checkout, and deleting the +directory removes every trace of it. + +## What the runner sends — read before running + +`run_jenner.sh` uploads only a bundle's SAS **source text** — `autoexec.sas` +plus `script.sas`, and nothing else — over HTTPS to `api.jenneranalytics.com`, +where it runs and returns the log and listing. It does not read or upload any +data files, so anything sitting next to a script stays on your machine; the +only thing transmitted is the code you run, as when pasting a snippet into any +hosted tool. Nothing is sent unless you run a command yourself. To point the +runner at a different endpoint, set `JENNER_HOST`. + +## What's in here + +``` +jenner-check/ +├── README.md # this file +├── run_jenner.sh # runner (bash + curl; python3 used if present) +└── tNNN_/ + ├── script.sas # the SAS under test (derived from this repo) + ├── autoexec.sas # run options prepended to script.sas at run time + ├── expected.json # fields pinned from the captured passing run + ├── meta.json # provenance: source file, blob sha, commit + └── expected/ # human-readable snapshot of that run + ├── log.txt + └── output.txt +``` + +`meta.json` records the source path, blob sha, and commit of the script each +bundle was built from, so you can verify the copy matches your code. + +## How to run + +From inside this directory: + +```bash +./run_jenner.sh --list # show the bundles in this directory +./run_jenner.sh --all # run every bundle, verify pinned fields +./run_jenner.sh tNNN_ # run just one (use a name from --list) +``` + +For each bundle the runner submits the SAS, writes the JSON response to +`_response.json`, and compares the response against the bundle's +`expected.json` — status, exit code, and the pinned log lines. A bundle +passes only when every pinned field matches, so an `N pass, 0 fail` summary +from `--all` means the captured results still hold. + +You can also compare a saved response offline, with no network call: + +```bash +./run_jenner.sh --compare tNNN__response.json tNNN_/expected.json +``` + +Requirements: `bash` 4+ and `curl` (both ship with mainstream Linux and +macOS); `python3` for the pinned-field comparison. On Windows, run it under +WSL. + +## Removing this directory + +`git rm -r jenner-check/` (or just delete the folder). Nothing else in the +repository references it. + +--- + +`run_jenner.sh` and this README are provided under the MIT license. Each +bundle's `script.sas` is a copy of code that already lives in this repository +and remains under this repository's own license — nothing here relicenses it. diff --git a/jenner-check/run_jenner.sh b/jenner-check/run_jenner.sh new file mode 100755 index 0000000..ad7825a --- /dev/null +++ b/jenner-check/run_jenner.sh @@ -0,0 +1,357 @@ +#!/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] +# ./run_jenner.sh --compare RESPONSE.json EXPECTED.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. +# +# --compare Purely local, no network: check RESPONSE.json against the +# pinned EXPECTED.json and print one line per check. Only +# keys present in EXPECTED.json are checked (keys starting +# with "_" are provenance and ignored): status / exit_code +# must match exactly, every log_contains entry must appear +# in the response log, no log_does_not_contain entry may +# appear, output_contains entries must appear in the output, +# and pinned diagnostics lists must match exactly. Unknown +# keys print a warning and do not fail. +# Exit 0 all-match, 1 any mismatch, 2 usage/unreadable file/ +# invalid JSON, 5 python3 unavailable. +# +# 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. +# +# What "pass" means: a bundle run passes only if the POST returns HTTP 200 +# AND — when the bundle carries an expected.json and python3 is available — +# the fresh response matches every pinned field (the --compare semantics +# above). Without python3 the pinned comparison is skipped with a notice +# and the run falls back to the old HTTP/status-only behavior. +# +# Requires: bash 4+, curl. Both ship with every mainstream Linux distro +# and macOS 12+. python3 (present on virtually every modern system) is +# optional but needed for the pinned-result comparison and the pretty +# summary. 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' "$(basename "$0")" + printf ' %s --compare RESPONSE.json EXPECTED.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")" + printf ' %s --compare r.json e.json # check a response against pinned fields\n' "$(basename "$0")" + } >&2 + exit "$status" +} + +# Compare a response.json against a pinned expected.json. Purely local — +# no network. Only keys present in expected.json are checked; keys whose +# name starts with "_" are capture provenance and are ignored. Prints one +# line per check (ok/FAIL/warn). Return codes: 0 all checks match, +# 1 any mismatch, 2 unreadable file or invalid JSON, 5 python3 missing. +compare_response_to_expected() { + local resp_file=$1 expected_file=$2 + if ! command -v python3 >/dev/null 2>&1; then + printf 'error: the pinned comparison needs python3, which was not found\n' >&2 + return 5 + fi + python3 - "$resp_file" "$expected_file" <<'PY' +import json, sys + +def load(path, label): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError) as e: + print(f"compare: cannot read {label} file {path}: {e}", file=sys.stderr) + sys.exit(2) + +resp = load(sys.argv[1], "response") +exp = load(sys.argv[2], "expected") +if not isinstance(resp, dict) or not isinstance(exp, dict): + print("compare: both files must contain a JSON object", file=sys.stderr) + sys.exit(2) + +failures = 0 + +def ok(msg): + print(f" ok {msg}") + +def bad(msg): + global failures + failures += 1 + print(f" FAIL {msg}") + +def as_list(v): + return v if isinstance(v, list) else [v] + +log = resp.get("log") or "" +output = resp.get("output") or "" + +for key, want in exp.items(): + if key.startswith("_"): + continue # provenance (_captured_at, ...), not a check + if key in ("status", "exit_code"): + got = resp.get(key) + if got == want: + ok(f"{key} == {want!r}") + else: + bad(f"{key}: expected {want!r}, got {got!r}") + elif key == "log_contains": + for s in as_list(want): + if s in log: + ok(f"log contains {s!r}") + else: + bad(f"log_contains: log is missing {s!r}") + elif key == "log_does_not_contain": + for s in as_list(want): + if s not in log: + ok(f"log does not contain {s!r}") + else: + bad(f"log_does_not_contain: log unexpectedly contains {s!r}") + elif key == "output_contains": + for s in as_list(want): + if s in output: + ok(f"output contains {s!r}") + else: + bad(f"output_contains: output is missing {s!r}") + elif key == "diagnostics": + diag = resp.get("diagnostics") or {} + for dk, dv in (want or {}).items(): + if dk.startswith("_"): + continue + got = diag.get(dk) + if got == dv: + ok(f"diagnostics.{dk} == {dv!r}") + else: + bad(f"diagnostics.{dk}: expected {dv!r}, got {got!r}") + else: + print(f" warn unknown expected.json key {key!r} — not checked") + +if failures: + print(f"pinned comparison: {failures} check(s) FAILED") + sys.exit(1) +print("pinned comparison: all pinned checks passed") +PY +} + +# --- 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 + ;; + --compare) + # Purely local: diff a response.json against a pinned expected.json. + if [[ $# -ne 3 ]]; then + printf 'usage: %s --compare RESPONSE.json EXPECTED.json\n' "$(basename "$0")" >&2 + exit 2 + fi + _rc=0 + compare_response_to_expected "$2" "$3" || _rc=$? + exit "$_rc" + ;; + --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 + +# --- pinned-result comparison --------------------------------------------- +# A bundle that ships an expected.json only passes if the fresh response +# matches every pinned field — HTTP 200 alone is not a pass. Without +# python3 we can't parse JSON portably, so fall back to the old behavior +# and say so. +if [[ -d "$TARGET" && -f "$TARGET/expected.json" ]]; then + if command -v python3 >/dev/null 2>&1; then + printf 'Comparing response against pinned %s/expected.json\n' "${TARGET%/}" + _rc=0 + compare_response_to_expected "$OUT" "$TARGET/expected.json" || _rc=$? + if [[ $_rc -ne 0 ]]; then + printf 'Bundle FAILED the pinned comparison (see lines above)\n' >&2 + exit "$_rc" + fi + else + printf 'pinned comparison skipped (python3 not found) — HTTP/status only\n' + fi +fi diff --git a/jenner-check/t001_macro_sard_stats_t_test/autoexec.sas b/jenner-check/t001_macro_sard_stats_t_test/autoexec.sas new file mode 100644 index 0000000..719f93f --- /dev/null +++ b/jenner-check/t001_macro_sard_stats_t_test/autoexec.sas @@ -0,0 +1,2 @@ +/* cap input rows for the captured run */ +options obs=100; diff --git a/jenner-check/t001_macro_sard_stats_t_test/expected.json b/jenner-check/t001_macro_sard_stats_t_test/expected.json new file mode 100644 index 0000000..6add6c1 --- /dev/null +++ b/jenner-check/t001_macro_sard_stats_t_test/expected.json @@ -0,0 +1,20 @@ +{ + "_captured_at": "2026-07-08T03:27:33.323241+00:00", + "_captured_run_id": "r_64537cfdb84548b7be7c07c349c7db36", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "NOTE: PROC TTEST data=ADSL", + "NOTE: ODS OUTPUT: STATISTICS -> sard_ttest_Statistics", + "NOTE: Wrote ard_ttest (11 rows, 7 columns).", + "NOTE: PROC PRINT completed: 11 observations printed, 7 variables" + ], + "log_does_not_contain": [ + "ERROR:", + "[JENNER-ERROR" + ], + "diagnostics": { + "parse_warnings": [], + "runtime_warnings": [] + } +} diff --git a/jenner-check/t001_macro_sard_stats_t_test/expected/log.txt b/jenner-check/t001_macro_sard_stats_t_test/expected/log.txt new file mode 100644 index 0000000..1c20141 --- /dev/null +++ b/jenner-check/t001_macro_sard_stats_t_test/expected/log.txt @@ -0,0 +1,61 @@ +NOTE: Option OBS changed to 100. +NOTE: DATA ADSL + +NOTE: Processing inline DATALINES (30 lines) + +NOTE: Read 30 rows from DATALINES. +NOTE: Wrote ADSL (30 rows, 5 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC TTEST data=ADSL + +NOTE: ODS OUTPUT: STATISTICS -> sard_ttest_Statistics +NOTE: ODS OUTPUT: TTESTS -> sard_ttest_Ttests +NOTE: ODS plot written: ttest_summary.spec.json +NOTE: PROC TTEST ODS Graphics generated. +NOTE: PROC TTEST statement used. +NOTE: DATA sard_ttest_Statistics_1 + + +NOTE: Read 3 rows from sard_ttest_Statistics. +NOTE: Wrote sard_ttest_Statistics_1 (2 rows, 15 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA sard_ttest_TTests_1 + + +NOTE: Read 2 rows from sard_ttest_Ttests. +NOTE: Wrote sard_ttest_TTests_1 (9 rows, 12 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: DATA ard_ttest + + +NOTE: Read 2 rows from sard_ttest_statistics_1. +NOTE: Read 11 rows from sard_ttest_ttests_1. +NOTE: Wrote ard_ttest (11 rows, 7 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC DELETE datasets=sard_ttest_statistics + +NOTE: Deleting SARD_TTEST_STATISTICS (memtype=DATA). +NOTE: 1 dataset(s) deleted. +NOTE: PROC DELETE datasets=sard_ttest_statistics_1 + +NOTE: Deleting SARD_TTEST_STATISTICS_1 (memtype=DATA). +NOTE: 1 dataset(s) deleted. +NOTE: PROC DELETE datasets=sard_ttest_ttests + +NOTE: Deleting SARD_TTEST_TTESTS (memtype=DATA). +NOTE: 1 dataset(s) deleted. +NOTE: PROC DELETE datasets=sard_ttest_ttests_1 + +NOTE: Deleting SARD_TTEST_TTESTS_1 (memtype=DATA). +NOTE: 1 dataset(s) deleted. +NOTE: PROC PRINT data=ard_ttest + +NOTE: PROC PRINT completed: 11 observations printed, 7 variables diff --git a/jenner-check/t001_macro_sard_stats_t_test/expected/output.txt b/jenner-check/t001_macro_sard_stats_t_test/expected/output.txt new file mode 100644 index 0000000..07a061f --- /dev/null +++ b/jenner-check/t001_macro_sard_stats_t_test/expected/output.txt @@ -0,0 +1,40 @@ +The TTEST Procedure + Variable: AGE + +TRT01PN N Mean Std Dev Std Err +---------- -------- -------- -------- -------- +1 15 50.6000 8.9746 2.3172 +2 15 52.5333 8.7331 2.2549 +Diff (1-2) -1.9333 + +TRT01PN Lower CL Mean Mean Upper CL Mean Lower CL Std Dev Std Dev Upper CL Std Dev +---------- ------------- -------- ------------- ---------------- -------- ---------------- +1 45.6301 50.6000 55.5699 6.5705 8.9746 14.1538 +2 47.6971 52.5333 57.3696 6.3937 8.7331 13.7729 +Diff (1-2) -8.5564 -1.9333 4.6897 7.0269 8.8546 11.9755 + +Method Variances DF t Value Pr > |t| +------------- --------- -------- -------- -------- +Pooled Equal 28 -0.60 0.5547 +Satterthwaite Unequal 28.0 -0.60 0.5547 + + Equality of Variances + +Method F Value Pr > F +-------- -------- -------- +Folded F 1.06 0.9201 + + + group1 variable stat_name stat_label stat fmt_fun Additional_Notes_in_SAS +------- -------- ----------- ------------------- ------------- ------------------------- ----------------------- +TRT01PN AGE estimate1 Group 1 Mean 50.6 1 TRT01PN=1 +TRT01PN AGE estimate2 Group 2 Mean 52.5333333333 1 TRT01PN=2 +TRT01PN AGE statistic t Statistic -0.5979517626 1 +TRT01PN AGE parameter Degrees of Freedom 27.9791932428 1 +TRT01PN AGE p.value p-value 0.5546817623 1 +TRT01PN AGE method method 2 2:Welch Two Sample t-test Satterthwaite +TRT01PN AGE alternative alternative 2 2:two.sided +TRT01PN AGE conf.level CI Confidence Level 0.95 1 +TRT01PN AGE mu H0 Mean 0 1 +TRT01PN AGE var.equal Equal Variances 0 0:N +TRT01PN AGE paired Paired t-test 0 0:N diff --git a/jenner-check/t001_macro_sard_stats_t_test/meta.json b/jenner-check/t001_macro_sard_stats_t_test/meta.json new file mode 100644 index 0000000..ac57586 --- /dev/null +++ b/jenner-check/t001_macro_sard_stats_t_test/meta.json @@ -0,0 +1,7 @@ +{ + "bundle": "t001_macro_sard_stats_t_test", + "source_file": "sARDenX/06_macro/sard_stats_t_test.sas", + "source_blob_sha": "02ab6cdb98dd2070e2f4bdd188544cf56ad1be79", + "source_commit": "4522e4a1c8f9e1e2ef9ab440ad1937aef542c721", + "notes": "Macro %sard_stats_t_test copied verbatim from the source file; a small mock ADSL (TRT01PN two-level group, AGE) is built inline via DATALINES (the README invites substituting a typical ADSL dataset) and the README usage example is the caller. Runs clean on Jenner 0.1.0 (PROC TTEST + ODS OUTPUT), producing the ARD-style long dataset ard_ttest." +} diff --git a/jenner-check/t001_macro_sard_stats_t_test/script.sas b/jenner-check/t001_macro_sard_stats_t_test/script.sas new file mode 100644 index 0000000..bbf09d5 --- /dev/null +++ b/jenner-check/t001_macro_sard_stats_t_test/script.sas @@ -0,0 +1,361 @@ +/* ------------------------------------------------------------------ * + * sARDenX compatibility bundle: %sard_stats_t_test + * + * The macro body below is copied verbatim from + * sARDenX/06_macro/sard_stats_t_test.sas + * The only additions are (1) a small mock ADSL dataset built inline so + * the bundle is self-contained, and (2) the README usage example as the + * caller. The README invites substituting a typical ADSL dataset, which + * is what the DATALINES block below does (TRT01PN two-level group, AGE). + * ------------------------------------------------------------------ */ + +data ADSL; + length USUBJID $12 TRT01P $9 SEX $1; + input USUBJID $ TRT01PN TRT01P $ AGE SEX $; +datalines; +STUDY-001 1 Placebo 45 M +STUDY-002 1 Placebo 52 F +STUDY-003 1 Placebo 38 M +STUDY-004 1 Placebo 61 F +STUDY-005 1 Placebo 47 M +STUDY-006 1 Placebo 55 F +STUDY-007 1 Placebo 43 M +STUDY-008 1 Placebo 66 F +STUDY-009 1 Placebo 50 M +STUDY-010 1 Placebo 58 F +STUDY-011 1 Placebo 41 M +STUDY-012 1 Placebo 49 F +STUDY-013 1 Placebo 63 M +STUDY-014 1 Placebo 37 F +STUDY-015 1 Placebo 54 M +STUDY-016 2 Active 48 F +STUDY-017 2 Active 59 M +STUDY-018 2 Active 44 F +STUDY-019 2 Active 67 M +STUDY-020 2 Active 51 F +STUDY-021 2 Active 46 M +STUDY-022 2 Active 62 F +STUDY-023 2 Active 39 M +STUDY-024 2 Active 57 F +STUDY-025 2 Active 53 M +STUDY-026 2 Active 42 F +STUDY-027 2 Active 65 M +STUDY-028 2 Active 50 F +STUDY-029 2 Active 60 M +STUDY-030 2 Active 45 F +; +run; + +/*** HELP START ***//* + +Purpose: + Performs a two-sample t-test using PROC TTEST and returns ARD-style results. + The macro outputs group means, confidence intervals, and test statistics + in a standardized long-format dataset. + +Inputs: + data - Input dataset. + class - Grouping (classification) variable with two levels. + var - Numeric analysis variable. + alpha - Significance level for confidence intervals (default: 0.05). + side - Test type for PROC TTEST: + 2 = two-sided (default), + L = one-sided lower-tail, + U/R = one-sided upper-tail. + h0 - Null hypothesis mean difference (default: 0). + Welch - Whether to use Welch/Satterthwaite method: + Y = Welch t-test (default), + N = Pooled-variance t-test. + +Outputs: + out - Output dataset in ARD-style structure with: + - estimate / conf.low / conf.high for each group mean + - estimate1 / estimate2 for group-specific means + - t statistic, degrees of freedom, p-value + - method, alternative, conf.level, mu, var.equal, paired + +Notes: + - Assumes exactly two groups in &class. + - One-sided direction depends on the ordering of CLASS levels in PROC TTEST. + - Intermediate datasets are created in WORK and deleted at the end. + +Example: + %sard_stats_t_test( + data=ADSL, + out=sard_stats_t_test, + class=TRT01PN, + var=AGE, + alpha=0.05, + side=2, + h0=0, + Welch=Y + ); + +*//*** HELP END ***/ + +%macro sard_stats_t_test( + data = , + out =sard_stats_t_test, + class = , + var = , + alpha = 0.05 , + side = 2, + h0 = 0, + Welch = Y +); + +proc ttest data = &data alpha=&alpha. side=&side. h0=&h0; + class &class; + var &var; + +ods output Statistics=sard_ttest_Statistics; +ods output TTests=sard_ttest_Ttests; + +run; + +data sard_ttest_Statistics_1; +length +stat_name +stat_label $200. +stat 8. +fmt_fun +context +$200. +; +set sard_ttest_Statistics; + +/*estimate*/ +call missing(of stat_name stat_label stat fmt_fun context); +%if %upcase(&Welch) ne Y %then %do; +if method = "Pooled" then do; +%end; +%if %upcase(&Welch) = Y %then %do; +if method = "Satterthwaite" then do; +%end; + stat_name ="estimate"; + stat_label="Group Mean"; + stat = Mean; + fmt_fun = "1"; + context=class; + output; +end; + +/*conf.low*/ +call missing(of stat_name stat_label stat fmt_fun context); +%if %upcase(&Welch) ne Y %then %do; +if method = "Pooled" then do; +%end; +%if %upcase(&Welch) = Y %then %do; +if method = "Satterthwaite" then do; +%end; + stat_name ="conf.low"; + stat_label="CI Lower Bound"; + stat = LowerCLMean; + fmt_fun = "1"; + output; +end; + +/*conf.high*/ +call missing(of stat_name stat_label stat fmt_fun context); +%if %upcase(&Welch) ne Y %then %do; +if method = "Pooled" then do; +%end; +%if %upcase(&Welch) = Y %then %do; +if method = "Satterthwaite" then do; +%end; + stat_name ="conf.high"; + stat_label="CI Upper Bound"; + stat = UpperCLMean; + fmt_fun = "1"; + output; +end; + +/*estimate1*/ +call missing(of stat_name stat_label stat fmt_fun context); +if _N_ = 1 then do; + stat_name ="estimate1"; + stat_label="Group 1 Mean"; + stat = Mean; + fmt_fun = "1"; + context =cats("&class.=",class); + output; +end; +/*estimate2*/ +call missing(of stat_name stat_label stat fmt_fun context); +if _N_ = 2 then do; + stat_name ="estimate2"; + stat_label="Group 2 Mean"; + stat = Mean; + fmt_fun = "1"; + context =cats("&class.=",class); + output; +end; + + +run; + + +data sard_ttest_TTests_1; +length +stat_name +stat_label $200. +stat 8. +fmt_fun +context +$200. +; +set sard_ttest_Ttests; +%if %upcase(&Welch) = Y %then %do; + where method ="Satterthwaite"; +%end; +%else %do; + where method ="Pooled"; +%end; + +/*statistic*/ +call missing(of stat_name stat_label stat fmt_fun context); +stat_name=cats("statistic"); +stat_label=cats("t Statistic"); +stat = tValue; +fmt_fun = cats(1); +output; + +/*parameter*/ +call missing(of stat_name stat_label stat fmt_fun context); +stat_name=cats("parameter"); +stat_label=cats("Degrees of Freedom"); +stat = DF; +fmt_fun = cats(1); +output; + +/*Probt*/ +call missing(of stat_name stat_label stat fmt_fun context); +stat_name=cats("p.value"); +stat_label=cats("p-value"); +stat = Probt; +fmt_fun = cats(1); +output; + +/*method*/ +call missing(of stat_name stat_label stat fmt_fun context); +stat_name=cats("method"); +stat_label=cats("method"); +select(method); + when("Pooled") do; + stat=1; + fmt_fun=catx(":",stat,"Two Sample t-test"); + context="Pooled"; + end; + when("Satterthwaite") do; + stat=2; + fmt_fun=catx(":",stat,"Welch Two Sample t-test"); + context="Satterthwaite"; + end; +end; +sas_raw_stat=cats(method); +output; + +/*alternative*/ +call missing(of stat_name stat_label stat fmt_fun context); +stat_name=cats("alternative"); +stat_label=cats("alternative"); +if "%upcase(&side)" = "2" then do; + stat =2; + fmt_fun = "2:two.sided"; +end; +else do; + stat=1; + fmt_fun = "1:one.sided (&side.)"; +end; +output; + +/*conf.level*/ +call missing(of stat_name stat_label stat fmt_fun context); +stat_name=cats("conf.level"); +stat_label=cats("CI Confidence Level"); +stat = 1 - &alpha.; +fmt_fun = cats(1); +output; + +/*H0 Mean*/ +call missing(of stat_name stat_label stat fmt_fun context); +stat_name=cats("mu"); +stat_label=cats("H0 Mean"); +stat = &h0.; +fmt_fun = cats(1); +output; + +/* var.equal*/ +call missing(of stat_name stat_label stat fmt_fun context); +stat_name=cats("var.equal"); +stat_label=cats("Equal Variances"); +select(method); + when("Pooled") do; + stat=1; + fmt_fun=catx(":",stat,"Y"); + end; + when("Satterthwaite") do; + stat=0; + fmt_fun=catx(":",stat,"N"); + end; +end; +output; + +/*paired*/ +call missing(of stat_name stat_label stat fmt_fun context); +stat_name=cats("paired"); +stat_label=cats("Paired t-test"); +stat=0; +fmt_fun=catx(":",stat,"N"); +output; + +run; + +data &out.; +length +group1 +variable +context +stat_name +stat_label +$200. +stat +8. +fmt_fun +Additional_Notes_in_SAS $200. +; +set + sard_ttest_statistics_1(rename=(context=Additional_Notes_in_SAS)) + sard_ttest_ttests_1(rename=(context=Additional_Notes_in_SAS)) +; +context="stats_t_test"; +group1="%upcase(&class)"; +variable="%upcase(&var)"; +keep group1--Additional_Notes_in_SAS ; +run; + +proc delete data =sard_ttest_statistics; +run; +proc delete data =sard_ttest_statistics_1; +run; +proc delete data =sard_ttest_ttests; +run; +proc delete data =sard_ttest_ttests_1; +run; + + +%mend; + +/* caller: README usage example, run against the mock ADSL above */ +%sard_stats_t_test( + data=ADSL, + out=ard_ttest, + class=TRT01PN, + var=AGE, + alpha=0.05, + side=2, + h0=0, + Welch=Y +); + +proc print data=ard_ttest noobs; run;