AI as a QC Double-Programmer in R: Can It Be the Independent Check?

Let an AI copilot write the independent re-derivation in double-programming QC and diffdf catches a real production bug — then see the false-clean that shows why an AI is not a qualified independent reviewer, and why a human owns the sign-off

Pharma & Clinical

Double-programming QC validates a derived clinical dataset by having an independent programmer re-derive it from the spec and comparing the two with diffdf. Can an AI copilot be that second programmer? This lesson runs both sides of the answer on public pharmaverse data. In the first scenario an AI-written re-derivation catches a genuine production bug — a baseline BMI that forgot to square the height — and diffdf pinpoints all 254 subjects. In the second, a terse, ambiguous spec makes the production code and the AI QC make the identical unit slip, diffdf reports “No issues were found!”, and that clean report is false. The honest boundary: an AI can draft QC code and be a useful second pair of eyes, but its independence is not guaranteed and it cannot hold accountability — a human owns the QC sign-off.

Published

July 2, 2026

Modified

July 7, 2026

TipKey takeaways
  • An AI copilot can write the independent re-derivation in double-programming QC. Hand it the same specification, ask it to re-derive the variable a different way, and compare with diffdf() — the AI plays the second programmer, fast.
  • When the two derivations genuinely differ, it works. A production BMI (Body Mass Index, weight in kg over height in metres squared) that forgot to square the height runs clean and looks plausible; the AI-written re-derivation disagrees on all 254 subjects, and diffdf() names every one.
  • The catch: an AI is not an independent second programmer. Given the same terse, ambiguous spec, the production code and the AI QC make the same unit slip — and diffdf() reports “No issues were found!”. A clean report between two correlated derivations is not validation.
  • A false clean looks exactly like a real one. Only a truly independent re-derivation — one that checks the units metadata the ambiguous spec left out — catches the shared error (254 differences).
  • AI drafts QC code; a human owns the QC. An AI copilot is a useful second pair of eyes, but its output is itself unqualified, its independence is not guaranteed, and it cannot sign a QC record. The qualified programmer owns the independent check and the accountability.

Introduction

Double programming — also called independent programming — is how a derived clinical dataset earns trust. A production programmer builds the dataset that will be submitted; a second, independent programmer, working from the same specification but writing their own code, re-derives it; the two are compared, and if they match, the derivation is validated. QC (quality control) here means exactly that: independent re-derivation, not a code review. The mechanics of the comparison live in Double programming in R with diffdf — keys, tolerance, strict typing, reading the report. This lesson does not re-teach them. It asks a different, sharper question: can an AI copilot be that independent second programmer?

The answer is a genuine “yes, and” — yes, it can write a fast re-derivation that catches real bugs; and the very property that makes double programming work — independence — is the one thing an AI cannot guarantee. We run both halves on public pharmaverse data, so every diffdf() line executes. First an AI QC that catches a real production bug. Then the honest twist: a false clean, where the AI agrees with a wrong answer as confidently as it would agree with a right one.

The QC task: baseline BMI on ADVS

The variable under QC is a derived baseline BMI. We build it from ADVS (the vital-signs analysis dataset), a BDS (Basic Data Structure — one row per subject per parameter per visit) dataset that carries WEIGHT in kilograms and HEIGHT in centimetres. We take each subject’s baseline record — the row flagged ABLFL == "Y" (the baseline-record flag) — and reshape weight and height side by side. Baseline height is measured once at screening, so it exists for 254 of the enrolled subjects; that is the analysis set.

library(pharmaverseadam)
library(dplyr)
library(tidyr)
data("advs", package = "pharmaverseadam")

# Baseline weight + height, one row per subject (ABLFL == "Y")
bl <- advs %>%
  filter(PARAMCD %in% c("WEIGHT", "HEIGHT"), ABLFL == "Y") %>%
  select(USUBJID, PARAMCD, AVAL) %>%
  distinct() %>%
  pivot_wider(names_from = PARAMCD, values_from = AVAL) %>%
  as.data.frame()

nrow(bl)                       # 254 subjects with a baseline weight and height
[1] 254
head(bl, 3)
      USUBJID HEIGHT WEIGHT
1 01-701-1015 147.32  54.43
2 01-701-1023 162.56  80.29
3 01-701-1028 177.80  99.34

The specification for the derivation is the textbook one: BMI = weight (kg) / height (m)². Height is recorded in centimetres, so it must be divided by 100 before squaring. Computed correctly, the values land in a physiologically sensible range — a fact worth pinning down now, because it is the plausibility anchor both scenarios lean on:

library(pharmaverseadam)
library(dplyr)
library(tidyr)
data("advs", package = "pharmaverseadam")
bl <- advs %>%
  filter(PARAMCD %in% c("WEIGHT", "HEIGHT"), ABLFL == "Y") %>%
  select(USUBJID, PARAMCD, AVAL) %>%
  distinct() %>%
  pivot_wider(names_from = PARAMCD, values_from = AVAL) %>%
  as.data.frame()

# The correct derivation: kg / m^2, height converted cm -> m
correct_bmi <- bl$WEIGHT / (bl$HEIGHT / 100)^2
range(correct_bmi)             # roughly 13.7 to 40.2 -- real human BMIs
[1] 13.66745 40.16536

Roughly 13.7 to 40.2 — real adult BMIs. Keep that range in mind: a derivation that produces numbers far outside it is wrong on its face, and one that produces numbers inside it can still be wrong in a way no range check will catch. Both traps are coming.

Scenario A: the AI QC catches a real production bug

Here the specification is unambiguous: BMI is weight in kilograms divided by height in metres squared; height is in centimetres, so divide by 100 first. We seed a realistic production bug — the kind that survives a glance at the output. The production programmer forgot to square the height:

library(pharmaverseadam)
library(dplyr)
library(tidyr)
data("advs", package = "pharmaverseadam")
bl <- advs %>%
  filter(PARAMCD %in% c("WEIGHT", "HEIGHT"), ABLFL == "Y") %>%
  select(USUBJID, PARAMCD, AVAL) %>%
  distinct() %>%
  pivot_wider(names_from = PARAMCD, values_from = AVAL) %>%
  as.data.frame()

# PRODUCTION (buggy): forgot the exponent -> weight / (height in metres), not squared
prod_bmi <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT / 100))
range(prod_bmi$BMI)            # 23 to 65 -- LOOKS like a plausible BMI table
[1] 23.09259 65.29281

This is why the bug is dangerous: the buggy output ranges from about 23 to 65. A reviewer skimming the table sees numbers that read like BMIs, and moves on. The error does not announce itself.

Now play the QC step with an AI copilot. Handed the same specification (not the production code) and asked to re-derive the variable independently, an AI produces the straightforward formula from the spec: kilograms over metres squared. This is the re-derivation an AI copilot returns from that prompt:

library(pharmaverseadam)
library(dplyr)
library(tidyr)
data("advs", package = "pharmaverseadam")
bl <- advs %>%
  filter(PARAMCD %in% c("WEIGHT", "HEIGHT"), ABLFL == "Y") %>%
  select(USUBJID, PARAMCD, AVAL) %>%
  distinct() %>%
  pivot_wider(names_from = PARAMCD, values_from = AVAL) %>%
  as.data.frame()

# AI QC re-derivation FROM THE SPEC: kg / m^2, height squared
ai_qc <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT / 100)^2)
range(ai_qc$BMI)              # 13.7 to 40.2 -- the correct range
[1] 13.66745 40.16536

Two derivations of the same variable, from the same spec, written by two different “programmers” — one of them the AI. Double programming now asks the only question that matters: do they agree? Compare them with diffdf(), keying on the subject identifier so it matches by subject, not row order.

library(pharmaverseadam)
library(dplyr)
library(tidyr)
library(diffdf)
data("advs", package = "pharmaverseadam")
bl <- advs %>%
  filter(PARAMCD %in% c("WEIGHT", "HEIGHT"), ABLFL == "Y") %>%
  select(USUBJID, PARAMCD, AVAL) %>%
  distinct() %>%
  pivot_wider(names_from = PARAMCD, values_from = AVAL) %>%
  as.data.frame()

prod_bmi <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT / 100))
ai_qc    <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT / 100)^2)

# keys = "USUBJID": match subjects by identity, not by row position
diffdf(prod_bmi, ai_qc, keys = "USUBJID")
Differences found between the objects!

Summary of BASE and COMPARE
  ====================================
    PROPERTY      BASE        COMP    
  ------------------------------------
      Name      prod_bmi     ai_qc    
     Class     data.frame  data.frame 
    Rows(#)       254         254     
   Columns(#)      2           2      
  ------------------------------------


Not all Values Compared Equal
  =============================
   Variable  No of Differences 
  -----------------------------
     BMI            254        
  -----------------------------


First 10 of 254 rows are shown in table below
  ===========================================
   VARIABLE    USUBJID      BASE    COMPARE  
  -------------------------------------------
     BMI     01-701-1015  36.94678  25.07927 
     BMI     01-701-1023  49.39099  30.38324 
     BMI     01-701-1028  55.87177  31.42394 
     BMI     01-701-1033  50.46788  28.79600 
     BMI     01-701-1034  40.40274  26.07638 
     BMI     01-701-1047  45.17801  30.40447 
     BMI     01-701-1097  46.19028  27.34609 
     BMI     01-701-1111  37.84125  23.91384 
     BMI     01-701-1115  43.46126  23.93110 
     BMI     01-701-1118  39.49207  21.89868 
  -------------------------------------------

diffdf() reports Differences found between the objects! — every one of the 254 subjects (BMI 254 differences), with the first ten spelled out: BASE (production) against COMPARE (the AI QC), value by value. The discrepancy — the disagreement between the two derivations — is total, and it points straight at the bug. Subject 01-701-1015 reads 36.9 in production against 25.1 in the AI QC; the production number is the un-squared one. The AI-written independent re-derivation did exactly what a QC programmer’s would: it surfaced a genuine production error the eye slid past.

Fix production — square the height — and re-run the comparison:

library(pharmaverseadam)
library(dplyr)
library(tidyr)
library(diffdf)
data("advs", package = "pharmaverseadam")
bl <- advs %>%
  filter(PARAMCD %in% c("WEIGHT", "HEIGHT"), ABLFL == "Y") %>%
  select(USUBJID, PARAMCD, AVAL) %>%
  distinct() %>%
  pivot_wider(names_from = PARAMCD, values_from = AVAL) %>%
  as.data.frame()

# Production, corrected: height squared
prod_fixed <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT / 100)^2)
ai_qc      <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT / 100)^2)

diffdf(prod_fixed, ai_qc, keys = "USUBJID")
No issues were found!

No issues were found! — the two independent derivations now agree on every subject. In this scenario the AI QC earned its keep: an independent re-derivation caught a real bug and confirmed the fix. This is the case for using an AI as a fast second pair of eyes. Now the case against trusting it.

Scenario B: the false clean

Real specifications are rarely as tidy as Scenario A’s. Suppose the spec the production programmer and the QC programmer both receive is terse: “BMI = WEIGHT / HEIGHT².” No units clause. The column is literally named HEIGHT, and it holds centimetres — but the spec does not say so, and a programmer in a hurry reads the column at face value as metres. The production code makes that slip:

library(pharmaverseadam)
library(dplyr)
library(tidyr)
data("advs", package = "pharmaverseadam")
bl <- advs %>%
  filter(PARAMCD %in% c("WEIGHT", "HEIGHT"), ABLFL == "Y") %>%
  select(USUBJID, PARAMCD, AVAL) %>%
  distinct() %>%
  pivot_wider(names_from = PARAMCD, values_from = AVAL) %>%
  as.data.frame()

# PRODUCTION: reads HEIGHT (cm) literally as metres -> forgot the /100 conversion
prod_unit <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT)^2)
range(prod_unit$BMI)          # ~0.0025 -- absurd, but nobody has looked yet
[1] 0.001366745 0.004016536

Now the AI QC. Handed the same terse spec and the same column named HEIGHT, an AI copilot pattern-matches to the most literal reading — WEIGHT / HEIGHT² — and makes the identical slip. It has no more reason than the hurried human to suspect the units, and it was trained on the same conventions that produced the ambiguous spec in the first place:

library(pharmaverseadam)
library(dplyr)
library(tidyr)
data("advs", package = "pharmaverseadam")
bl <- advs %>%
  filter(PARAMCD %in% c("WEIGHT", "HEIGHT"), ABLFL == "Y") %>%
  select(USUBJID, PARAMCD, AVAL) %>%
  distinct() %>%
  pivot_wider(names_from = PARAMCD, values_from = AVAL) %>%
  as.data.frame()

# AI QC: same terse spec, same literal reading of HEIGHT -> the SAME unit slip
ai_unit <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT)^2)

Compare the two with diffdf(), exactly as before:

library(pharmaverseadam)
library(dplyr)
library(tidyr)
library(diffdf)
data("advs", package = "pharmaverseadam")
bl <- advs %>%
  filter(PARAMCD %in% c("WEIGHT", "HEIGHT"), ABLFL == "Y") %>%
  select(USUBJID, PARAMCD, AVAL) %>%
  distinct() %>%
  pivot_wider(names_from = PARAMCD, values_from = AVAL) %>%
  as.data.frame()

prod_unit <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT)^2)
ai_unit   <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT)^2)

diffdf(prod_unit, ai_unit, keys = "USUBJID")
No issues were found!

No issues were found! — and this clean report is a lie. Both derivations are wrong (every BMI is about 0.0025, off by a factor of 10,000), but they are wrong in the same way, so diffdf() finds nothing to disagree about. This is the failure mode double programming is supposed to prevent: two implementations that share an error will always agree, and their agreement looks identical to real validation. The report is byte-for-byte the same “No issues were found!” you signed off on in Scenario A.

What catches it is genuine independence. A truly independent QC programmer does not just re-type the terse formula — they check the source: HEIGHT in ADVS is recorded in centimetres, so it must be converted before squaring. That re-derivation disagrees with the shared error on every subject:

library(pharmaverseadam)
library(dplyr)
library(tidyr)
library(diffdf)
data("advs", package = "pharmaverseadam")
bl <- advs %>%
  filter(PARAMCD %in% c("WEIGHT", "HEIGHT"), ABLFL == "Y") %>%
  select(USUBJID, PARAMCD, AVAL) %>%
  distinct() %>%
  pivot_wider(names_from = PARAMCD, values_from = AVAL) %>%
  as.data.frame()

prod_unit  <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT)^2)
# TRULY independent: checks the units metadata (HEIGHT is cm) and converts
qc_correct <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT / 100)^2)

diff <- diffdf(prod_unit, qc_correct, keys = "USUBJID", suppress_warnings = TRUE)
diffdf_has_issues(diff)       # TRUE -> 254 differences, the shared bug exposed
[1] TRUE
diff
Differences found between the objects!

Summary of BASE and COMPARE
  ====================================
    PROPERTY      BASE        COMP    
  ------------------------------------
      Name     prod_unit   qc_correct 
     Class     data.frame  data.frame 
    Rows(#)       254         254     
   Columns(#)      2           2      
  ------------------------------------


Not all Values Compared Equal
  =============================
   Variable  No of Differences 
  -----------------------------
     BMI            254        
  -----------------------------


First 10 of 254 rows are shown in table below
  ==============================================
   VARIABLE    USUBJID       BASE      COMPARE  
  ----------------------------------------------
     BMI     01-701-1015  0.002507927  25.07927 
     BMI     01-701-1023  0.003038324  30.38324 
     BMI     01-701-1028  0.003142394  31.42394 
     BMI     01-701-1033  0.002879600  28.79600 
     BMI     01-701-1034  0.002607638  26.07638 
     BMI     01-701-1047  0.003040447  30.40447 
     BMI     01-701-1097  0.002734609  27.34609 
     BMI     01-701-1111  0.002391384  23.91384 
     BMI     01-701-1115  0.002393110  23.93110 
     BMI     01-701-1118  0.002189868  21.89868 
  ----------------------------------------------

diffdf_has_issues() returns TRUE, and the report names all 254 subjects. The difference between Scenario B’s false clean and this true catch is not the tool — diffdf() did exactly what it was told both times. The difference is independence: a QC that shares the production code’s assumption produces a “No issues found” that looks like validation and isn’t. (suppress_warnings = TRUE just stops the difference from also surfacing as an R warning; the report is the same.)

Why an AI is not an independent second programmer

Scenario B is not a quirk of one prompt. It is structural, and it is exactly what the discipline’s own guidance warns about. The PHUSE guidance on independent QC is explicit that the premise of double programming is that two programmers who autonomously create an identical output can be considered correct — and that “it is vital that programmers do not look at each other’s programs.” It also warns that “the dataset specifications can have more than one potential interpretation” and that even with two programmers “there is still room for human error.” An AI copilot breaks that premise in three concrete ways:

  • Correlated errors. The independence assumption requires that the two programmers’ mistakes are uncorrelated — that a wrong assumption by one is unlikely to be shared by the other. An AI trained on the same conventions and the same ambiguous patterns as everyone else reproduces the most common reading, which is precisely the reading a hurried production programmer is most likely to have used. The errors are correlated by construction, and correlated errors defeat double programming (Scenario B).
  • Silent false agreement. The AI returns “the derivation matches” with the same confidence whether the shared answer is right or wrong. It has no signal that distinguishes a genuine clean from a false one — the clean report in Scenario B was as calm and complete as the one in Scenario A.
  • No accountability. Double programming ends in a sign-off: a named, qualified programmer attests that the independent check was performed and passed, and answers for it in an audit. An AI cannot sign a QC record, cannot be the accountable second programmer, and cannot be held responsible for a wrong number that reaches a submission.

The R Validation Hub frames the general principle for the tools underneath this work: assurance comes from documented, human-owned validation, not from a tool’s own say-so. An AI copilot sits inside that process as a fast drafter of QC code — never as the independent reviewer who certifies it.

Who owns what

Line up the two scenarios and the boundary is exact. In Scenario A the AI copilot wrote a re-derivation that caught a real production bug — genuine, useful work, a fast second pair of eyes. In Scenario B the same copilot produced a false clean, agreeing with a wrong answer because it shared the wrong assumption. Nothing about the AI changed between the two; what changed was whether its derivation was actually independent of production’s error, and that is the one property double programming depends on and the AI cannot guarantee.

So the accountability never moves. A human — the qualified QC programmer — owns the independent re-derivation (including the judgement to question the spec and check the units metadata that Scenario B turned on), owns reading the diffdf() report as evidence rather than a verdict, and owns the sign-off that a regulator relies on. This is a GxP deliverable (the “good practice” quality regulations governing clinical data), and validation of a derived dataset means documented, independent, human-owned verification — the standard CDISC ADaM (Analysis Data Model) sets for analysis data. An AI can draft the QC code and accelerate the drafting; it is never the validation. Put plainly: use an AI as a second pair of eyes, never as the independent reviewer of record — and never read a diffdf() “No issues were found!” as “validated” when the same AI wrote both sides.

🟢 With an AI agent

Ask Prova “An AI wrote my independent QC re-derivation and diffdf says ‘No issues were found!’ — how do I know that clean report is real and not a shared error?” — it answers grounded in this series’ validation lessons, with the runnable diffdf() double-programming and independence checks you can try on your own derivation. The runtime is the judge. Ask Prova →

Common issues

Reading “No issues were found!” as “validated” when an AI wrote both sides. A clean diffdf() report only means two derivations agree; it means validated only if the two are genuinely independent. Scenario B produced the exact same clean report from two derivations that shared a units bug. If the same AI (or the same person, or a copied program) produced both the production code and the QC re-derivation, their agreement carries no validation weight — the independence that makes double programming work is missing. Establish that the QC path is independent before you trust its clean report.

Passing a pivot_wider() result straight to diffdf(). pivot_wider() returns a tibble, and mixing tibble and data-frame inputs can register spurious class differences. Wrap the reshaped data in as.data.frame() (as every block here does) so diffdf() compares values, not container types. Running distinct() before the pivot also avoids list-columns when a subject has more than one baseline record.

Forgetting keys, so diffdf() matches by row order. Without keys = "USUBJID", diffdf() lines the two data frames up by position; if production and QC sorted their output differently, it flags every row. Always key on the column(s) that uniquely identify a subject so the comparison matches by identity. (The full mechanics — non-unique keys, tolerance, strict typing — are covered in Double programming in R with diffdf.)

Frequently asked questions

As a drafting tool, yes — as the independent reviewer of record, no. An AI copilot can write the QC re-derivation from the specification faster than you can type it, and when its derivation genuinely differs from production it catches real bugs (Scenario A above). But the AI’s QC code is itself unqualified, and a qualified human must own the sign-off. Use it to draft and accelerate the check, never to certify it.

No. Double programming works because the two programmers are independent, so their mistakes are unlikely to be the same. An AI trained on the same conventions and ambiguous patterns as everyone else tends to reproduce the most common reading — the same one a hurried production programmer used — so its errors are correlated with production’s, which is exactly what breaks the method (Scenario B). The PHUSE guidance on independent QC makes independence the whole premise.

Because diffdf() compares two datasets for agreement; it cannot know whether the agreed-upon value is correct. When production and the AI QC both made the same units mistake, their outputs were identical, so diffdf() correctly reported no differences — a false clean. A clean report validates a derivation only when the two paths that produced it are genuinely independent; a truly independent re-derivation that checked the units metadata disagreed on all 254 subjects.

A qualified human programmer, always. The AI’s re-derivation is an unqualified draft; a named QC programmer reviews it, confirms it is genuinely independent of the production logic, reads the diffdf() report as evidence, and attests to the result in the audit trail. Accountability for a derived value that reaches a submission never transfers to the tool — this is the boundary the auditable AI SOP for clinical programming formalises.

The mechanics are the same diffdf(base, compare, keys = ...) call taught in Double programming in R with diffdf. What changes here is who writes the second derivation and what its agreement is worth. When an AI writes the QC side, a clean diffdf() report can be a false clean, because the AI may share production’s assumption. The lesson is about the independence the report silently assumes, not about the comparison itself.

Test your understanding

A colleague QCs a derived BMI variable by asking an AI copilot to re-derive it from the same one-line spec the production programmer used, then runs diffdf(prod, ai_qc, keys = "USUBJID") and gets No issues were found!. They mark the variable validated.

  1. In prose: explain the one condition under which that clean report does validate the derivation, and the one under which it does not — and why the report looks identical either way.
  2. In R: build a tiny two-subject example where HEIGHT is in centimetres, write a prod and an ai_qc BMI that both forget the /100 conversion, confirm diffdf() reports no issues, then add a truly independent re-derivation that converts the units and show it disagrees.

diffdf() tests whether two derivations agree, not whether they are correct. The clean report validates only if prod and ai_qc are genuinely independent; if they share the same wrong assumption (the missing unit conversion), they agree and the report is a false clean. A third derivation that divides HEIGHT by 100 before squaring will disagree with both.

Step 1. The clean report validates the derivation only if the production and QC derivations are independent — written from the spec without sharing code or assumptions — so that a shared mistake is unlikely. It does not validate when the two paths are correlated (here, the same AI, or the same literal misreading of an ambiguous spec, produced both): they make the identical error, agree exactly, and diffdf() reports “No issues were found!” — a false clean that is byte-for-byte identical to a real one. The report cannot tell you which it is; only knowing the QC path is genuinely independent can.

library(diffdf)

bl <- data.frame(
  USUBJID = c("01-001", "01-002"),
  WEIGHT  = c(70, 90),            # kg
  HEIGHT  = c(175, 180)           # cm -- must be /100 before squaring
)

# prod and ai_qc share the same unit bug (no /100 conversion)
prod  <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT)^2)
ai_qc <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT)^2)
diffdf(prod, ai_qc, keys = "USUBJID")          # "No issues were found!" -- FALSE clean

# a truly independent re-derivation that checks the units (HEIGHT is cm)
qc_correct <- data.frame(USUBJID = bl$USUBJID, BMI = bl$WEIGHT / (bl$HEIGHT / 100)^2)
diffdf_has_issues(diffdf(prod, qc_correct, keys = "USUBJID", suppress_warnings = TRUE))  # TRUE

The first diffdf() is clean because the two derivations share the bug; the independent one flags both subjects. Same tool, opposite verdicts — the difference is independence, not diffdf().

A. Yes — two derivations agreed, which is what double programming requires. B. Not necessarily — if the AI shares production’s assumption, both can be wrong in the same way and agree, so the clean report can be a false clean; a qualified human must confirm the QC was genuinely independent and own the sign-off. C. Yes, as long as a current, capable model wrote the QC code.

B. Double programming validates only when the two derivations are independent. An AI can reproduce the same wrong assumption as production (correlated errors), in which case the two agree and diffdf() reports “No issues were found!” for a shared bug — a false clean identical to a real one, which is why A is wrong. The capability of the model does not create independence (ruling out C); only a qualified human confirming the QC path is genuinely independent, and owning the sign-off, makes the clean report validation.

Conclusion

An AI copilot can play the second programmer in double-programming QC, and Scenario A shows the upside: handed the spec, it wrote an independent re-derivation that caught a real production bug — a baseline BMI that forgot to square the height — and diffdf() named all 254 subjects. That is a fast, useful second pair of eyes. But Scenario B shows the boundary that never moves: given a terse, ambiguous spec, the AI made the same unit slip as production, diffdf() reported a clean “No issues were found!”, and that clean report was false. The property double programming depends on — independence — is the one an AI cannot guarantee, because its errors correlate with the conventions everyone else shares. So an AI drafts QC code; a qualified human owns the independent check, reads the report as evidence, and signs off. AI changes the speed of the QC draft. It changes nothing about who is accountable for the number. The runtime is the judge.

Note

This lesson is reproducible: the QC task, the Scenario A bug and its catch, the Scenario B false clean, and the truly-independent re-derivation that exposes it all reproduce here on public pharmaverse data. Copy any block and run it to reproduce them. The AI drafting step happens in your own tooling; the R that performs and tests the QC is real and executes. The runtime is the judge.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {AI as a {QC} {Double-Programmer} in {R:} {Can} {It} {Be} the
    {Independent} {Check?}},
  date = {2026-07-02},
  url = {https://www.datanovia.com/learn/pharma-clinical/08-agentic-clinical-programming/ai-as-qc-double-programmer},
  langid = {en}
}
For attribution, please cite this work as:
“AI as a QC Double-Programmer in R: Can It Be the Independent Check?” 2026. July 2. https://www.datanovia.com/learn/pharma-clinical/08-agentic-clinical-programming/ai-as-qc-double-programmer.