Skip to content

Add Model 2a: same-parameter cross-biomarker covariance as a JAGS extension of model.jags (strictly nests Chapter 1) + Chapter-1 comparison#245

Open
Kwan-Jenny wants to merge 9 commits into
mainfrom
chapter2-extend
Open

Add Model 2a: same-parameter cross-biomarker covariance as a JAGS extension of model.jags (strictly nests Chapter 1) + Chapter-1 comparison#245
Kwan-Jenny wants to merge 9 commits into
mainfrom
chapter2-extend

Conversation

@Kwan-Jenny

Copy link
Copy Markdown
Collaborator

Summary

Adds Model 2a, an additive extension of model.jags that introduces same-parameter cross-biomarker covariance into the subject-level antibody kinetic parameters while leaving every Chapter 1 within-biomarker block unchanged. Chapter 1 is recovered exactly when the new covariances are zero, so Model 2a strictly nests the current model. It also adds compare_mod_2a(), a head-to-head of Chapter 1 vs Model 2a on the same data.

Nothing in the existing Chapter 1 pipeline is modified — model.jags, prep_data(), prep_priors(), run_mod() are untouched; all code is new.

What Model 2a is

Chapter 1 covariance is biomarker-separated (cross-biomarker = 0):

Sigma = [ Sigma_G   0     ]
        [   0     Sigma_A  ]

Model 2a keeps Sigma_G, Sigma_A and adds a diagonal cross-biomarker
block C = diag(c_1..c_P) (same-parameter only):

Sigma = [ Sigma_G   C      ]      C = diag(c_p)
        [   C^T    Sigma_A  ]

c_p is the IgG~IgA covariance for the same kinetic parameter p; cross-parameter cross-biomarker terms stay 0; C = 0 reproduces Chapter 1 (35 covariance parameters vs 30).

Implementation (factor parameterization)

A Wishart precision cannot pin a sparse covariance's zero-pattern, so the exact pattern is generated with a shared latent factor per kinetic parameter:

par[i,k,p] = mu.par[k,p] + w[i,k,p] + lambda[k,p] * f[i,p]
w[i,k,1:P] ~ dmnorm(0, prec.par[k,,])   # Chapter 1 Wishart block, unchanged
f[i,p]     ~ dnorm(0, 1)                 # shared factor, one per parameter

For two biomarkers this gives c_p = lambda[1,p] * lambda[2,p] (derived from the loadings — this is a model quantity, independent of the sampler; it is the same c_p whether implemented in JAGS or Stan), cross-parameter cross-biomarker
covariance 0, and — with all lambda = 0 — exactly model.jags. The first biomarker's loadings are constrained > 0 for identifiability; the sign of c_p stays free via lambda[2,p]. The model is general in n_antigen_isos: with 2 biomarkers it is exactly Model 2a; with more, the single per-parameter factor couples all of them (rank-1, same-parameter).

inst/extdata/model_2a.jags is identical to model.jags except this par construction and the loading priors.

What's added

  • inst/extdata/model_2a.jags — the extended model.
  • Priors: add_factor_priors(), prep_priors_2a() (reuse Chapter 1 priors).
  • Fitting: jags_data_2a(), make_inits_2a(), run_mod_2a() — a lean wrapper that reuses prep_data() unchanged and returns the MCMC plus a tidy cross-biomarker covariance/correlation summary (no sr_model post-processing).
  • Post-processing (small pure helpers): cross_cov_from_loadings(), marginal_var_2a(), cross_cor_from_draw_2a(), summarize_cross_2a(), shared-internal jags_node_utils_2a.R.
  • Simulation + validation: build_sigma_2a(), sim_params_2a(), sim_case_data_2a(), validate_recovery_2a(), validate_nesting_2a().
  • Comparison: fit_chapter1_lean() (same posterior as run_mod()), summarize_curve_params_2a(), compare_mod_2a().

No new dependencies (all in Imports already); no new data (uses nepal_sees).

Chapter 1 vs Model 2a — what changes

compare_mod_2a() fits both models on the same data and reports:

  • What stays the same — the shared population means mu.par and the within-biomarker variances. Because Model 2a strictly nests Chapter 1, these should agree within MCMC error; a large difference would signal a problem, not
    an improvement.
  • What Model 2a adds — the cross-biomarker covariance c_p / correlation rho_p per kinetic parameter, which Chapter 1 cannot represent (it is structurally 0 there).
  • Where improvement would show — the means are unchanged by design; the value of Model 2a lives in the covariance and in covariance-dependent quantities (credible intervals, joint posterior prediction, DIC/WAIC). A rigorous "is it better" evaluation (WAIC/LOO; downstream time-since-infection / seroincidence MAE/RMSE/coverage) is a planned follow-up, not this PR. compare_mod_2a(dic = TRUE) offers a best-effort DIC as a first signal.

Comparison results on nepal_sees (full-length MCMC, run locally):

(to be filled in from the local comparison run: convergence (max R-hat), the shared-parameter table with max |diff|, the cross-biomarker c_p/rho_p table and which CIs exclude 0, and the mu.par CI-width summary)

Verification

  • CI runs the pure-function unit tests + a tiny-MCMC smoke test that run_mod_2a() / compare_mod_2a() execute and return the expected structure (matching the existing test-run_mod.R style; CI installs JAGS).
  • Recovery of a known cross-biomarker correlation from simulated data, the no-false-positive check on independent data, and the Chapter 1 vs Model 2a comparison on nepal_sees, are run locally at full MCMC length (validate_recovery_2a(), validate_nesting_2a(), compare_mod_2a()); results pasted above.

How to try it

data(nepal_sees)              # already case_data; HlyE_IgG, HlyE_IgA
fit <- run_mod_2a(nepal_sees)
fit$cross                 # IgG ~ IgA covariance & correlation per parameter
cmp <- compare_mod_2a(nepal_sees)
cmp$shared; cmp$added     # shared params (Ch1 vs 2a) + the addition

Scope

This PR is the "add the model + show it nests Chapter 1 + compare to Chapter 1" increment. Quantifying improvement (WAIC/LOO, joint posterior-predictive comparison, seroincidence coverage/width) is a follow-up, since WAIC needs a
model-file change and the seroincidence step needs the serocalculator integration.

…AGS extension of model.jags

Add Model 2a, an additive extension of model.jags that introduces
same-parameter cross-biomarker covariance via a shared per-parameter latent
factor, leaving every Chapter 1 within-biomarker block unchanged. Chapter 1 is
recovered exactly when the loadings are zero (strict nesting).

- model: inst/extdata/model_2a.jags
- fit: run_mod_2a() with jags_data_2a, make_inits_2a, prep_priors_2a,
  add_factor_priors; reuses prep_data() unchanged
- cross-biomarker summaries: summarize_cross_2a, cross_cov_from_loadings,
  marginal_var_2a, cross_cor_from_draw_2a (+ internal jags_node_utils)
- comparison vs Chapter 1: compare_mod_2a, fit_chapter1_lean,
  summarize_curve_params_2a
- simulation/validation: sim_params_2a, sim_case_data_2a, build_sigma_2a,
  validate_recovery_2a, validate_nesting_2a
- tests: 7 pure-function unit tests + 1 JAGS-gated smoke test
- example: run_mod_2a-examples.R (one fit on nepal_sees)

No new dependencies; no Chapter 1 code modified.
@claude

This comment has been minimized.

@codecov

codecov Bot commented Jun 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.20900% with 46 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
R/validate_recovery_2a.R 0.00% 15 Missing ⚠️
R/validate_nesting_2a.R 0.00% 10 Missing ⚠️
R/build_sigma_2a.R 71.42% 6 Missing ⚠️
R/summarize_cross_2a.R 83.78% 6 Missing ⚠️
R/jags_data_2a.R 69.23% 4 Missing ⚠️
R/cross_cov_from_loadings.R 40.00% 3 Missing ⚠️
R/compare_mod_2a.R 97.14% 1 Missing ⚠️
R/prep_priors_2a.R 75.00% 1 Missing ⚠️
Files with missing lines Coverage Δ
R/add_factor_priors.R 100.00% <100.00%> (ø)
R/cross_cor_from_draw_2a.R 100.00% <100.00%> (ø)
R/fit_chapter1_lean.R 100.00% <100.00%> (ø)
R/jags_node_utils_2a.R 100.00% <100.00%> (ø)
R/make_inits_2a.R 100.00% <100.00%> (ø)
R/marginal_var_2a.R 100.00% <100.00%> (ø)
R/run_mod_2a.R 100.00% <100.00%> (ø)
R/sim_case_data_2a.R 100.00% <100.00%> (ø)
R/sim_params_2a.R 100.00% <100.00%> (ø)
R/summarize_curve_params_2a.R 100.00% <100.00%> (ø)
... and 8 more

Merge remote-tracking branch 'origin/main' into chapter2-extend

# Conflicts:
#	DESCRIPTION
#	inst/WORDLIST
@claude

This comment has been minimized.

@claude

This comment has been minimized.

@claude

This comment has been minimized.

@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

📖 https://ucd-serg.github.io/serodynamics/preview/pr245
Preview documentation for this PR (at commit 3889475)

Tooling/style only; model and functions unchanged. stop()->cli_abort,
line/indent/semicolon fixes, object_name_linter exemption for *_2a files,
sim_params tolerance 0.05, suppressWarnings on JAGS smoke tests,
@claude

This comment has been minimized.

@claude

This comment has been minimized.

@Kwan-Jenny

Copy link
Copy Markdown
Collaborator Author

@claude Please fix the remaining open review comments 1–3 on branch chapter2-extend in one commit.

  1. In R/compare_mod_2a.R, lines 60–64, update the dplyr::left_join() call:
  • Replace by = c("biomarker", "param") with by = dplyr::join_by(biomarker, param).
  • Add relationship = "one-to-one" as an explicit argument to the join.

The corrected join should follow this structure:

shared <- dplyr::left_join(
s1,
s2,
by = dplyr::join_by(biomarker, param),
suffix = c("_ch1", "_aa"),
relationship = "one-to-one"
)
  1. In R/jags_data_2a.R, lines 29–30, flatten the "i" CLI warning message so the string is written on one line with no embedded newline or leading indentation whitespace. Keep the same message meaning, but make it a single-line string.

Use this single-line version:

"i" = "found {n_bio} biomarkers; shared factor couples all (rank-1). subset to 2 for a clean pair."
  1. In R/sim_case_data_2a.R, remove the hardcoded assumption that there are exactly 2 biomarkers:
  • Replace line 37 with:
rows <- vector("list", n * length(biomarkers))
  • Replace line 40 with:
for (k in seq_along(biomarkers)) {

Please only make these targeted changes for comments 1–3.

@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Kwan-Jenny's task in 1m 8s —— View job


Tasks

  • Read the three target files
  • Apply fix 1: compare_mod_2a.Rjoin_by + relationship = "one-to-one"
  • Apply fix 2: jags_data_2a.R — flatten CLI message to a single line
  • Apply fix 3: sim_case_data_2a.R — replace n * 2 and 1:2 with length(biomarkers) / seq_along(biomarkers)
  • Commit and push

Summary

All three targeted fixes applied and pushed as commit b3ba634 on chapter2-extend:

  1. R/compare_mod_2a.R:60-66dplyr::left_join() now uses by = dplyr::join_by(biomarker, param) and explicitly sets relationship = "one-to-one" per CLAUDE.md.
  2. R/jags_data_2a.R:29 — Multi-biomarker warning string flattened to one line; cli no longer renders an embedded newline plus indentation.
  3. R/sim_case_data_2a.R:37,40 — Pre-allocation is now n * length(biomarkers) and the inner loop is for (k in seq_along(biomarkers)), so the simulator honours the full biomarkers argument instead of silently dropping past the second.

Note on fix 1

I kept suffix = c("_ch1", "_2a") rather than the c("_ch1", "_aa") shown in the example snippet — the rest of compare_mod_2a() references mean_med_2a / var_med_2a columns (lines 65–68), so changing the suffix to _aa would break those lookups. Let me know if you actually want the suffix renamed and I'll update the downstream column references too.
· Branch

github-actions Bot and others added 2 commits June 20, 2026 20:11
- compare_mod_2a(): use join_by() and set relationship = "one-to-one"
  on the shared-parameter left_join (CLAUDE.md requirement).
- jags_data_2a(): flatten the multi-biomarker warning to a single line
  so cli does not render an embedded newline plus indentation.
- sim_case_data_2a(): drop the hardcoded n * 2 pre-allocation and
  for (k in 1:2) loop so the simulator honours length(biomarkers).

Co-authored-by: Kwan-Jenny <Kwan-Jenny@users.noreply.github.com>
@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Kwan-Jenny's task in 5m 27s —— View job


Code Review: Model 2a PR #245 — after commits b3ba634 and ff6f7b1

Tasks

  • Load tools and set up
  • Read changed R source files and latest commits
  • Check prior review items were resolved
  • Check for new issues (style, correctness, tests)
  • Compile and post review

1 re-opened item — jags_data_2a.R:29-30

CLI warning string re-split across two lines (minor — carried over from previous review, re-introduced in ff6f7b1)

Commit b3ba634 correctly flattened this to one line. Commit ff6f7b1 ("fix lint problem") reverted it:

- "i" = "found {n_bio} biomarkers; shared factor couples all (rank-1). subset to 2 for a clean pair."
+ "i" = "found {n_bio} biomarkers; shared factor couples all (rank-1). 
+       subset to 2 for a clean pair."

The revert was necessary because the single-line version exceeds the 80-character limit enforced by line_length_linter (which is on by default via linters_with_defaults). The embedded newline + leading whitespace renders literally in CLI output as an indent mid-sentence.

The fix is to shorten the message to fit within 80 characters on one line — no change to intent:

# current — 104 chars → linter error; or 2-line split → embedded newline
"i" = "found {n_bio} biomarkers; shared factor couples all (rank-1). \n      subset to 2 for a clean pair."

# fix — 80 chars exactly, fits in one line
"i" = "found {n_bio} biomarkers; rank-1 factor couples all. Subset to 2."

Fix this →


All previous-review items: status

Item File Status
left_join() missing relationship compare_mod_2a.R:60-66 ✓ Fixed (join_by() + relationship = "one-to-one" + # nolint for linter)
sim_case_data_2a hardcoded 1:2 sim_case_data_2a.R:37,40 ✓ Fixed (length(biomarkers) / seq_along)
@returns doc on run_mod_2a run_mod_2a.R ✓ Present
stop()cli::cli_abort() all four files ✓ Done
base::merge()dplyr::left_join() compare_mod_2a.R ✓ Done
fit_chapter1_lean memory footprint fit_chapter1_lean.R ✓ Lean (monitors mu.par, prec.par only)
Positional probs row indexing summarize_cross_2a.R:72-74 Still open — low priority (unchanged)
CLI warning embedded newline jags_data_2a.R:29-30 ⚠ Re-opened (see above)

JAGS model and math ✓

inst/extdata/model_2a.jags is unchanged and correct:

  • Factor construction par = mu.par + w + lambda * f is sound
  • Identifiability constraint lambda[1,p] T(0,) is correct
  • lambda[k,p] ~ dnorm(0, prec.lambda) for k >= 2 leaves the sign of c_p free via lambda[2,p]
  • w ~ dmnorm(zero_p, prec.par) with the Chapter 1 Wishart prior is unchanged
  • Setting all lambda = 0 reduces to Chapter 1 exactly

The marginal variance algebra in marginal_var_2a(), the cross-covariance formula in cross_cov_from_loadings(), and the correlation in cross_cor_from_draw_2a() are all mathematically correct.


Tests ✓

Unit tests cover:

  • build_sigma_2a — block placement, Chapter 1 nesting, PD rejection
  • cross_cov_from_loadings, marginal_var_2a, cross_cor_from_draw_2a — algebraic correctness
  • summarize_cross_2a — end-to-end with synthetic fixed draws
  • sim_params_2a — large-n covariance recovery
  • sim_case_data_2a — shape, biomarker labels, reproducibility
  • summarize_curve_params_2a — means, variances (with and without loadings)
  • make_inits_2a, add_factor_priors — structure and validation

validate_recovery_2a and validate_nesting_2a have 0% CI coverage by design (full-MCMC; documented as locally-run). The 0% lines in cross_cov_from_loadings.R are the error branch (nrow < 2), which is acceptable.


Summary

1 item to resolve before merge: fix the re-introduced embedded newline in jags_data_2a.R:29-30 by shortening the warning message to fit within 80 characters on one line. Everything else — model math, dplyr idioms, documentation, tests, and the three items fixed in b3ba634 — is clean.
· Branch

@Kwan-Jenny

Copy link
Copy Markdown
Collaborator Author

@claude The only CI failure now is an R CMD check NOTE, plus one re-opened review item. Please make these two minimal changes and KEEP them — do not re-introduce join_by() and do not split the CLI string. Then run
devtools::document(), styler::style_pkg(), lintr::lint_package() (must be clean), and devtools::check() (must be 0 errors / 0 warnings / 0 notes), and commit.

  1. compare_mod_2a.R (the left_join, ~line 60).
    The NOTE "no visible binding for global variable 'biomarker'/'param'" is caused by join_by(biomarker, param): the bare column names are flagged by R CMD check's codetools as undefined globals. # nolint only silences lintr, not R CMD check, so the NOTE remains. Switch back to a CHARACTER by (no NSE) while keeping the one-to-one assertion. Use exactly:
   shared <- dplyr::left_join(
     s1, s2,
     by = c("biomarker", "param"),   # character `by`: no NSE, so this avoids
     relationship = "one-to-one",    # the R CMD check no-visible-binding NOTE
     suffix = c("_ch1", "_2a")
   )

Remove the now-unnecessary # nolint on that join. Do NOT use join_by() here — character by is intentional to pass R CMD check.

  1. jags_data_2a.R (~lines 29-30).
    The cli "i" message keeps oscillating between a 1-line version (>80 chars, fails line_length_linter) and a 2-line split (the embedded newline renders as a literal mid-sentence indent in CLI output). Fix it permanently by SHORTENING the message to fit on ONE physical line <= 80 characters INCLUDING its indentation. Use:
   "i" = "found {n_bio} biomarkers; rank-1 factor couples all, subset to 2."

Keep it on a single line; do not add a line break inside the string. If it is still >80 with the current indentation, shorten the wording further (keep the meaning) rather than splitting the line.

@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Kwan-Jenny's task in 12m 38s —— View job


Claude finished @Kwan-Jenny's task — · Branch

Tasks

  • Read compare_mod_2a.R and jags_data_2a.R
  • Fix 1: switch compare_mod_2a.R left_join back to character by, remove # nolint
  • Fix 2: shorten CLI message in jags_data_2a.R to single line ≤ 80 chars
  • Run lintr::lint() on both target files
  • Run lintr::lint_package() (full package)
  • Run devtools::check()
  • Commit and push

Result

Pushed as commit 3889475 on chapter2-extend. The diff is exactly the two requested changes — R/compare_mod_2a.R and R/jags_data_2a.R, 5 insertions / 7 deletions, no other files touched.

1) R/compare_mod_2a.R (lines 60–65)dplyr::left_join now uses character by, # nolint removed:

shared <- dplyr::left_join(
  s1, s2,
  by = c("biomarker", "param"),   # character `by`: no NSE, so this avoids
  relationship = "one-to-one",    # the R CMD check no-visible-binding NOTE
  suffix = c("_ch1", "_2a")
)

2) R/jags_data_2a.R (line 29) — single line, 79 chars including its 6-space indent:

"i" = "found {n_bio} biomarkers; rank-1 factor couples all, subset to 2."

Verification

  • lintr::lint("R/compare_mod_2a.R") and lintr::lint("R/jags_data_2a.R"): No lints found. for both.
  • lintr::lint_package(): 19 pre-existing warnings remain (all in test files I did not touch — test-run_mod.R, spelling.R, test-cross_cov_2a.R, test-serodynamics_example.R). None are in either changed file or anywhere else I edited; they were there on ff6f7b1 before this commit.
  • devtools::check(): 0 errors / 0 warnings / 3 NOTEs. The original "no visible binding for global variable 'biomarker'/'param'" NOTE is gone — that was the target of fix 1. The 3 remaining NOTEs are all pre-existing repo infrastructure:
    • Version contains large components (0.1.0.9001) — DESCRIPTION version style
    • Found the following hidden files and directories: .claude-pr — bot scratch dir
    • Examples with CPU ... > 5s: run_mod_2a 2.278 0.111 42.961 — JAGS sampler runtime

Notes on what I did not run

  • styler::style_pkg(): I ran it once and it reformatted 80+ unrelated files (R/utils.R, all data-raw/*.r, many test files, vignettes, .Rprofile, etc.) — that would have produced a sprawling diff well outside the "two minimal changes" scope of this task, so I reverted those changes and only kept the two targeted edits. If you actually want a full package re-style as its own commit, say the word and I'll do that as a separate PR/commit.
  • devtools::document(): no roxygen comments changed in the two files, so man/*.Rd regeneration would be a no-op. Skipped to keep the diff to two files.
  • The 19 pre-existing lint warnings and 3 pre-existing NOTEs: all out of scope for "two minimal changes". Happy to clean them up in a follow-up if you'd like (the test-file indentation issues are mechanical).

- compare_mod_2a.R: switch dplyr::left_join back to character `by` so
  R CMD check's codetools no longer flags `biomarker`/`param` as
  undefined globals. Keep `relationship = "one-to-one"` and drop the
  obsolete `# nolint` (lintr no longer needs to be silenced).
- jags_data_2a.R: replace the two-line "i" cli message with a single
  line under 80 chars, so cli no longer renders a literal mid-sentence
  indent and line_length_linter stays clean.

Co-Authored-By: Kwan-Jenny <Kwan-Jenny@users.noreply.github.com>
@Kwan-Jenny

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Kwan-Jenny's task in 2m 53s —— View job


Code Review: Model 2a PR #245 — after commits 3889475, ff6f7b1, b3ba634

Tasks

  • Read all changed R source files at HEAD
  • Verify previous review items remain resolved
  • Check JAGS model file and math
  • Check tests
  • Compile findings

Previously raised items — status

Item File Status
left_join() missing relationship compare_mod_2a.R:60-65 ✓ Fixed (character by, relationship = "one-to-one", # nolint removed)
sim_case_data_2a hardcoded 1:2 sim_case_data_2a.R:37,40 ✓ Fixed (length(biomarkers) / seq_along(biomarkers))
CLI multi-line warning jags_data_2a.R:29 ✓ Fixed (79 chars on one line, including indent)
@returns doc on run_mod_2a run_mod_2a.R ✓ Present
stop()cli::cli_abort() all four files ✓ Done
base::merge()dplyr::left_join() compare_mod_2a.R ✓ Done
fit_chapter1_lean memory footprint fit_chapter1_lean.R ✓ Lean (monitors mu.par, prec.par only)

The model file inst/extdata/model_2a.jags, the factor construction (par = mu + w + lambda * f), the identifiability constraint lambda[1, p] T(0,), and the marginal-variance algebra in marginal_var_2a() / cross_cov_from_loadings() / cross_cor_from_draw_2a() are all mathematically correct. Setting lambda = 0 reduces to Chapter 1 exactly. ✓


New findings

1. sim_case_data_2a.R:33-41 — latent inconsistency between the (now-generalized) loop and the 2-biomarker sim_params_2a() (minor — correctness)

Fix 3 generalized the outer loop to for (k in seq_along(biomarkers)), but sim_params_2a() still hardcodes two biomarkers (it builds mu = c(mu_g, mu_a) and returns an n × 2P log_par). So a caller who passes biomarkers = c("A", "B", "C") will hit truth$log_par[i, ((k-1)*p+1):(k*p)] with k = 3 → columns 11:15 of a 10-column matrix → subscript-out-of-bounds. The docs (@param biomarkers Length-2 character) still say length 2, so this is a latent assumption, not a current crash — but the loop now lies about its own generality. Cheapest fix is an early assert near the top:

if (length(biomarkers) != 2L) {
  cli::cli_abort("{.arg biomarkers} must have length 2 (Model 2a is a pair).")
}

Alternatively, generalize sim_params_2a() to take a list of mean vectors. The targeted fix-3 instruction was the right local change; an assert would make the constraint explicit. Fix this →

2. summarize_cross_2a.R:39-41, 67-74probs is row-indexed positionally, not by sorted probability (minor — silent-wrong-result risk)

probs is user-facing (defaulting to c(0.025, 0.5, 0.975)) and only validated for length 3. apply(..., quantile, probs = probs) returns rows in the order of probs, then the code reads cov_q[1, ] as cov_lo, cov_q[2, ] as cov_med, cov_q[3, ] as cov_hi. A call like summarize_cross_2a(fit$mcmc, probs = c(0.5, 0.025, 0.975)) populates cov_med with the lower quantile with no error. One-line fix right after the length check:

probs <- sort(probs)

Fix this →

3. compare_mod_2a.R:74-79try(..., silent = TRUE) returns a "try-error" object, not NULL (minor — return-type consistency)

The roxygen at lines 40 says "dic_ch1, dic_2a: raw DIC objects when dic = TRUE (else NULL)". But on failure try() returns a "try-error" object, so dic_ch1 ends up as a try-error rather than NULL. Use tryCatch(..., error = function(e) NULL) if the documented contract ("NULL or DIC object") is the intended behavior. Fix this →

4. compare_mod_2a.R:43-55... is forwarded to both fits, which leaks Model-2a-only args (minor — API design)

... is forwarded to both fit_chapter1_lean(...) (which forwards to prep_priors()) and run_mod_2a(...) (which has extra_monitors, prec_lambda, plus its own ...). prec_lambda is named explicitly, fine. But extra_monitors = "y0" via ... would route to prep_priors() on the Chapter 1 side and either silently drop or error. The "shared = Chapter 1 prior args only" constraint isn't documented anywhere. Suggest either documenting it explicitly in @param ..., or pulling out a monitors_2a parameter.

5. CLAUDE.md guidance now contradicts the dplyr join in compare_mod_2a.R (documentation only, out of scope for this PR)

CLAUDE.md says "Prefer dplyr::left_join() with by = join_by(...)". The fix here intentionally uses character by to avoid the R CMD check NOTE that the bare-symbol join_by() produces. Both forms work; the maintainer's preference is documented at the call site via the inline comment. A short note in CLAUDE.md (when a join's bywould otherwise trigger a no-visible-binding NOTE for the bare column names, characterby = c(...) is the accepted form) would close the loop for future reviewers and prevent a fourth round-trip.


Tests, lint, NAMESPACE — clean

  • NAMESPACE exports all 17 new Model 2a functions plus existing initsfunction.
  • .lintr.R exempts the _2a files from object_name_linter only (correct narrow scope; the 2a suffix can't satisfy snake_case ACRO regex).
  • Unit tests cover the pure algebra (build_sigma_2a, cross_cov_from_loadings, marginal_var_2a, cross_cor_from_draw_2a), the extraction (summarize_cross_2a, summarize_curve_params_2a) with synthetic constant draws, structure checks (make_inits_2a, add_factor_priors, sim_case_data_2a), large-n covariance recovery (sim_params_2a), and a tiny-MCMC smoke test for run_mod_2a + compare_mod_2a. validate_recovery_2a / validate_nesting_2a 0% coverage is by design.

Summary

The strict-nesting story holds and the implementation is mathematically sound. Three small things worth fixing before merge:

  1. Either add a length(biomarkers) == 2L assert in sim_case_data_2a or document the constraint clearly (R/sim_case_data_2a.R:33).
  2. probs <- sort(probs) in summarize_cross_2a so positional row indexing is safe (R/summarize_cross_2a.R:41).
  3. Swap try(..., silent = TRUE) for tryCatch(..., error = function(e) NULL) to honor the documented NULL-on-failure return type in compare_mod_2a (R/compare_mod_2a.R:74-79).

Item 4 (... forwarding) and item 5 (CLAUDE.md note about character by) are documentation-only nudges.
· Branch

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant