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.
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).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.Tick each step as you finish it (full detail for the model pieces is in Runtime & hardware and the README):
install.packages(c("reticulate","digest"))pip install llama-cpp-python (no admin, no daemon)tlf.slm.model_path / TLF_SLM_GGUF at itRscript selftest.R (no model needed) → expect SELFTEST PASSquarto render interpret_tlf.qmd — drafts come out marked DRAFT — review requiredThe 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
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 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.
reticulate → llama-cpp-python, GGUF). Runs the model in-process from R via a user-space Python wheel — no Ollama, no system service, no network, no admin rights (the things that get blocked on locked-down work laptops). Sovereignty + no data egress + a frozen GGUF file (pinned by name + SHA) so the same facts reproduce the same draft.temperature = 0, fixed seed → reproducible drafts; logged in the audit record alongside the model digest and a hash of the prompt and facts.APPROVED without biostatistician sign-off. The Quarto doc renders drafts as DRAFT — biostatistician review required and shows exactly which facts the draft was grounded on, so review is fast and auditable..qmd per TLF (or a master doc looping the TLF list), freeze: true + renv lockfile for reproducible reports.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.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.
| Model | GGUF (size) | Params | Fit on 32 GB / 4 GB | Note |
|---|---|---|---|---|
MedGemma 1.5 4B-IT (google/medgemma-1.5-4b-it) | Q4_K_M ≈ 2.6 GB | 4B dense (Gemma 3 base) | fits 4 GB VRAM (full offload) or RAM | recommended 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 GB | 25.2B (3.8B active, MoE) | RAM + ~12–15 GB headroom; CPU-first or MoE-offload | heavier alternative — more capable, slower on this laptop; Apache-2.0; QAT keeps Q4 quality high |
| Gemma 3 1B | Q4 ≈ 0.8 GB | 1B dense | fully in 4 GB VRAM | ultra-light; weakest, tight slot-filling only |
python convert_hf_to_gguf.py google/medgemma-1.5-4b-it --outfile medgemma-1.5-4b-it-f16.gguf
./llama-quantize medgemma-1.5-4b-it-f16.gguf ~/models/medgemma-1.5-4b-it-Q4_K_M.gguf Q4_K_M
orunsloth/bartowski GGUF, when available) — verify the publisher and record the SHA.mmproj/vision projector); the facts→prose task needs no image input.n_gpu_layers = 0 (pure CPU/RAM), n_ctx = 4096 — always works.TLF_SLM_GPU_LAYERS=999 puts the whole ~2.6 GB model on the GPU; drop toward ~28 if you hit a VRAM OOM (the KV cache + compute buffer also need room).options(tlf.slm.tensor_overrides = ".ffn_.*_exps.=CPU", tlf.slm.gpu_layers = 999) (recent llama-cpp-python; = llama.cpp --override-tensor ".ffn_.*_exps.=CPU" -ngl 999).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).
1 was actually the SAE count); (b) spelled-out numbers (“twenty-four”) bypass the digit regex — the prompt requires digits, but the human gate is the backstop.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.
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
interpret_tlf.qmdparameterized Quarto template (params: tlf_id, result dataset path)
_quarto.ymlproject config (freeze: auto, embed-resources, reproducibility)
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
Real failure modes from R/slm_client.R and README.md — symptom → why → fix.
| Symptom | Why | Fix |
|---|---|---|
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. |