TLF Interpretation Drafter QUARTO + ON-PREM SLM

A Quarto + on-prem small-language-model pipeline that drafts the narrative text accompanying each Table/Listing/Figure — the model never sources a number; every draft is number-validated and human-gated.

Honest status — design + runnable skeleton, not validated software. This is a drafting aid behind hard guardrails. It does not replace the biostatistician and it produces no reported number. Every reported figure comes only from the validated TLF result dataset (traceable to Phoenix WinNonlin / Pinnacle 21 / EDC per SOP) — never from an LLM. Treat all model output as draft text for human authoring, under ops governance.
🧰 Before you start ▶ Quick-start & run 🧩 What it does 🖥 Runtime & hardware 🔌 Wire-in 🆘 Troubleshooting 💻 Browse the code

Before you start

You'll need: R (4.x) with the reticulate and digest packages  ·  a user Python venv (no admin) with llama-cpp-python installed  ·  one GGUF model file on disk (default ~/models/medgemma-1.5-4b-it-Q4_K_M.gguf; any Gemma 3 / Qwen3 GGUF works)  ·  Quarto only if you render the .qmd report. Target hardware: a 32 GB RAM / 4 GB VRAM work laptop. The selftest.R below needs none of the model pieces — the validators are pure R, and the client has a deterministic STUB fallback, so the skeleton runs offline with no model installed (clearly marked STUB).
No GGUF ships with this. Google publishes MedGemma as safetensors only — there is no official GGUF. Convert/quantize it yourself with recent llama.cpp, or use a trusted community quant (e.g. unsloth/bartowski) — verify the publisher and record the SHA. Pin the file by path + SHA so the frozen weights reproduce. This is not Ollama: the model runs in-process from R via reticulate → llama-cpp-python — no daemon, no port, no admin.

Quick-start & how to run

Tick each step as you finish it (full detail for the model pieces is in Runtime & hardware and the README):

Run it

The self-test proves the guardrails with no model installed (pure-R validators on a known-good draft and a hallucinated one). Run it from the tlf_interpret/ folder so the relative source("R/…") paths resolve:

cd tlf_interpret
Rscript selftest.R

Success ends with SELFTEST PASS (exit 0): the faithful draft → DRAFT_OK, the hallucinated one → REJECTED with its invented numbers (27, 90.0, 3) listed as unmatched. To draft with a real model, set the GGUF path and (optionally) full GPU offload, then render:

export TLF_SLM_GGUF="$HOME/models/medgemma-1.5-4b-it-Q4_K_M.gguf"
export TLF_SLM_GPU_LAYERS=999   # whole ~2.6 GB 4B on the 4 GB VRAM; drop toward ~28 on VRAM OOM. 0 = pure CPU
quarto render interpret_tlf.qmd

What it does

A pipeline that drafts the narrative interpretation that accompanies each Table/Listing/Figure (e.g. the AE-overview paragraph, the PK-parameter summary text), using an on-prem small language model (SLM), rendered with Quarto, and wired to the deterministic TLF outputs from ../r_tlf_pseudocode/ (or ../sas_tlf_pseudocode/).

This is a DESIGN + runnable skeleton, not a validated system. It is a drafting aid behind hard guardrails — it does not replace the biostatistician and it does not produce any reported number.

The one hard invariant

The SLM never sources a number. Every reported number comes from the validated TLF result dataset (which itself traces to Phoenix WinNonlin / Pinnacle 21 / EDC per SOP). The model only writes the prose around numbers it is handed, and that prose is then checked so that every number in the draft must already exist in the deterministic facts — otherwise it is rejected as a hallucination. The rendered table/figure in the report is the validated output, not anything the model produced.

Pipeline

validated TLF result dataset
(aggregate, no PHI)
→ fact extractor
(deterministic R)
→ facts
{typed numbers + labels}
→ local SLM
on-prem, frozen weights, temp 0, fixed seed
→ draft prose → validators
(deterministic R, NOT the model)
→ status
DRAFT_OK / FLAGGED / REJECTED
→ Quarto render
validated table/figure + DRAFT callout + facts + validator results
→ biostatistician reviews / edits / SIGNS OFF
human gate
→ APPROVED → CSR narrative + audit record
The constrained prompt to the SLM is pinned (fixed seed, temp 0, pinned model + digest): “use ONLY these facts; introduce no new numbers.” The validators are three deterministic R checks: (1) numeric-consistency — every number in the draft must be in the facts, else REJECT; (2) forbidden-claim — flag causal/efficacy/“safe”/“well tolerated” language for review; (3) template conformance — length, required slots. The audit record captures model id + digest, seed, prompt hash, facts hash, draft, approver, final text, timestamp.

Why each piece

Architecture — the runtime is NOT Ollama

The README's ASCII architecture diagram historically carried a stale “Ollama” label. The backend is not Ollama. The SLM runs in-process from R via reticulate calling a user-space llama-cpp-python wheel against a frozen GGUF file — no daemon, no port, no admin. The corrected runtime label is shown below.
R session → reticulate → llama-cpp-python
(no Ollama)
→ frozen GGUF model file
pinned by name + SHA, in-process

Runtime & hardware (32 GB RAM / 4 GB VRAM target)

The default is a dense 4B medical model that fits in the 4 GB VRAM at Q4 — a change from the earlier 26B-A4B MoE, which had to run CPU-first from the 32 GB RAM. Either works; pick by how much VRAM you have and how much capacity you want.

ModelGGUF (size)ParamsFit on 32 GB / 4 GBNote
MedGemma 1.5 4B-IT (google/medgemma-1.5-4b-it)Q4_K_M ≈ 2.6 GB4B dense (Gemma 3 base)fits 4 GB VRAM (full offload) or RAMrecommended default — medical-domain fine-tune; multimodal but used text-only; no official GGUF → convert/quant yourself; Gemma/HAI-DEF license
Gemma 4 26B-A4B-it QAT (Unsloth UD-Q4_K_XL)14.2 GB25.2B (3.8B active, MoE)RAM + ~12–15 GB headroom; CPU-first or MoE-offloadheavier alternative — more capable, slower on this laptop; Apache-2.0; QAT keeps Q4 quality high
Gemma 3 1BQ4 ≈ 0.8 GB1B densefully in 4 GB VRAMultra-light; weakest, tight slot-filling only

Getting a MedGemma GGUF (Google ships safetensors only — there is no official GGUF)

Running it on 4 GB VRAM (the 4B now fits, unlike the MoE)

Expect tens of tok/s with the 4B on the GPU (or ~5–15 tok/s CPU-only) → ~seconds per draft; fine for review-gated batch drafting. Keep n_ctx small (4096) — the facts+prompt+draft is <1K tokens, so a large context just wastes RAM, and on a busy work laptop (Outlook/Teams/browser open) close heavy apps to avoid swap.

Install once in a user venv: pip install llama-cpp-python; drop the .gguf on disk; point tlf.slm.model_path at it. No admin, no daemon, no network. If even reticulate is blocked, the same wheel exposes a local OpenAI-compatible server (python -m llama_cpp.server) on localhost — but in-process reticulate is preferred (no port, no process to manage).

Honest limits

Wire-in

Point extract_facts() at the result tibble a TLF program returns (e.g. the AE-overview counts, the t_pk_param_summary summary). The same pattern serves SAS: emit the result dataset, read it in R for fact extraction + rendering, or call the SLM client from SAS via a shell/PROC HTTP step.

Browse the code

R/ — pipeline scripts

R/slm_client.Ron-prem SLM client: reticulate → llama-cpp-python (GGUF, in-process); temp 0; fixed seed; audit; STUB fallback R/extract_facts.Rdeterministic per-TLF fact extractors (AE overview, PK parameters, ...) R/validate_interpretation.Rnumeric-consistency + claim + template validators (NOT the model) R/interpret.Rorchestrator: facts → SLM draft → validate → status + audit record

Quarto render layer

interpret_tlf.qmdparameterized Quarto template (params: tlf_id, result dataset path) _quarto.ymlproject config (freeze: auto, embed-resources, reproducibility)

Tests & docs

selftest.Rruns the validators on a known-good and a hallucinated draft (no model needed) README.mdfull design write-up, runtime/hardware table, honest limits

Troubleshooting

Real failure modes from R/slm_client.R and README.md — symptom → why → fix.

SymptomWhyFix
Drafts come out as [STUB DRAFT - no local SLM installed] No model loaded, so the client fell back to the deterministic STUB. slm_model() returns NULL when reticulate is missing, llama_cpp isn't importable in the active Python, or the GGUF file isn't found. Confirm all three: install.packages("reticulate"); in the venv pip install llama-cpp-python (and point reticulate at that venv); set TLF_SLM_GGUF to an existing .gguf. Expected — the selftest deliberately runs in STUB mode.
Rscript selftest.R → cannot open file 'R/validate_interpretation.R' The self-test sources its R files by relative path; you ran it from the wrong directory. Run from inside the folder: cd tlf_interpret && Rscript selftest.R.
Model load aborts with a CUDA / VRAM out-of-memory error TLF_SLM_GPU_LAYERS=999 offloaded the whole model, but the KV cache + compute buffer also need room on the 4 GB VRAM. Lower the offload toward TLF_SLM_GPU_LAYERS=28, or set 0 for guaranteed pure-CPU/RAM. Keep n_ctx at 4096 and close heavy apps to avoid swap.
Downloaded a MedGemma repo but there's no .gguf in it Google ships MedGemma as safetensors only — there is no official GGUF. Convert it (convert_hf_to_gguf.py → llama-quantize … Q4_K_M) or use a trusted community quant; verify the publisher and record the SHA. Then point tlf.slm.model_path at the result.
reticulate can't be installed / is blocked on the locked-down laptop Some managed machines block the in-process Python bridge. The same wheel exposes a local server: python -m llama_cpp.server on localhost. In-process reticulate is preferred (no port, no process), so use the server only as a fallback.
The 26B-A4B MoE model is unusably slow or won't fit The MoE alternative is CPU-first on this hardware; full GPU offload of all experts overflows 4 GB VRAM. Use the recommended 4B dense default instead, or keep experts on CPU: options(tlf.slm.tensor_overrides = ".ffn_.*_exps.=CPU", tlf.slm.gpu_layers = 999).
A draft passed the numeric check but states a wrong clinical claim (e.g. a number paired with the wrong quantity) By design: the validator checks each number exists in the facts, not that it's attached to the right noun; it does not certify clinical correctness. This is exactly what the mandatory human gate covers — the biostatistician reviews and signs off. The claim guard only surfaces risky phrasings (efficacy / causal / “well tolerated”); never treat a passing draft as a reported result.
Quarto report won't re-render after editing R code freeze is on for reproducibility, so a cached render is reused. Re-run quarto render interpret_tlf.qmd (clear the _freeze/ cache if needed). Reported tables/figures always come from the validated TLF output, never the model.