Skip to content

fix: restore a fit's ID column as a factor - #7

Open
mattfidler wants to merge 13 commits into
mainfrom
fix-restore-id-factor
Open

fix: restore a fit's ID column as a factor#7
mattfidler wants to merge 13 commits into
mainfrom
fix-restore-id-factor

Conversation

@mattfidler

Copy link
Copy Markdown
Member

Problem

The fit table round-trips through a plain .csv, so read.csv() brings ID back as an integer while a live fit carries a factor. Anything that joins the fit table against something derived from the fit then hits a type mismatch — nlme::augPred() keeps its id a factor.

ggPMX::pmx_nlmixr() on a cached fit died outright:

Error in `bmerge()`:
! Incompatible join types: x.ID (factor) and i.ID (integer).
  Factor columns must join to factor or character columns.

(nlmixr2's xgxr-nlmixr-ggpmx article, which loads its fits with :=.)

Fix

Put ID back to a factor in loadFit(). Doing it there rather than in the restore script written by saveFit() means caches written by earlier versions are repaired on load too — the restore script is a saved artifact inside the zip, so a save-side fix would only help fits saved from here on.

Levels come from ranef/etaObf, which the restore script has already put back as factors with the fit's own levels; failing that, from the order the IDs appear.

Tests

New assertions in test-save.R's round-trip block, for both the FOCEi and SAEM fits. Verified on the real cached article fit: ID restores as a factor with levels 31, 32, 33, ... taken from ranef, and the xgxr-nlmixr-ggpmx article now renders.

Pre-existing unrelated failure in that file (parHistData type factor levels — nlmixr2est has added "Analytic Gradient (relaxed)" and friends since the fallback level list was written) is unchanged by this PR; confirmed present on the base commit too.

🤖 Generated with Claude Code

The fit table round-trips through a plain .csv, so read.csv() brings ID back
as an integer while a live fit carries a factor.  Anything that joins the fit
table against something derived from the fit then hits a type mismatch --
nlme::augPred() keeps its `id` a factor, so ggPMX::pmx_nlmixr() on a cached
fit died in a data.table join with "Incompatible join types: x.ID (factor)
and i.ID (integer)".

Repaired in loadFit() rather than in the restore script written by saveFit(),
so caches written by earlier versions are fixed on load too.  Levels come
from ranef/etaObf, which the restore script has already put back as factors
with the fit's own levels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mattfidler and others added 6 commits August 6, 2026 23:48
nlmixr2est stores parHistData compressed unless control=list(compress=FALSE),
so the fit environment holds a raw vector rather than a data frame.  saveFit()
read the type levels straight off that, found no data frame, and recorded
nothing -- leaving loadFit() to fall back to its hardcoded level list.
nlmixr2est has since added types that list predates ("Analytic Gradient
(relaxed)" and friends), so those values came back as NA and the round-trip
test for the SAEM IOV fit failed (the test-coverage job on main).

Decompress with `fit$parHistData` before reading the levels, the same way the
save loop already handles every other raw item.  For caches saved before the
levels were recorded, the loader now appends any unrecognized type to the
fallback list rather than dropping it to NA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things antigravity's review of PR #7 turned up.

saveFit.nlmixr2FitData() wrote the fit .csv and then called the core method
with a hardcoded zip=TRUE, so `zip=FALSE` was silently ignored for every fit
that carries data -- which is the common case.  Pass `zip` through.

The PR claimed caches from earlier versions are repaired on load but only
tested freshly written ones.  Add a test that blanks `..id.level..` and
`..parHistType.level..` out of the env script and injects a parHistData type
the loader's fallback list predates, then asserts ID comes back a factor in
order of appearance (not a character sort, which would put "10" before "2")
and the unknown type is appended rather than dropped to NA.

Also correct the NEWS claim about that fallback: it lives in the restore
script inside the zip, so an existing cache only picks it up on re-save.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second round of antigravity review on PR #7.

The type levels are applied by the restore script stored *inside* the cache,
so a cache written before nlmixr2est added a type ("Analytic Gradient
(relaxed)" and friends) has no level for it and coerces it to NA on load.
Re-saving cannot recover it -- by then the string is gone -- so the previous
commit's NEWS claim that an old cache picks up the fix on re-save was wrong.

Repair it in loadFit() instead, the same way the ID column is repaired: the
-parHistData.csv still holds the original strings and has not been cleaned up
at that point, so read the column back and append whatever levels the script
was missing.  Existing caches are fixed in place.

Also add the test that distinguishes the two sources of ID levels: theo_sd's
IDs appear in level order, so the round-trip test could not tell a ranef-
derived level set from the appearance-order fallback.  Reversing the recorded
levels makes them disagree and pins down that ranef wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third round of antigravity review on PR #7, adversarial pass.

The list of files belonging to a fit was matched with an unanchored pattern,
so a cache named "fit" also matched "myfit-env.R": saveFit() zipped the other
cache's files into its own archive and then unlinked them, and loadFit()
deleted them on cleanup.  Previously this needed unzipped files to be lying
around; the preceding commit made saveFit(zip=FALSE) actually leave them, so
anchor the pattern.  Folded the three copies into one helper.

Two smaller hardening fixes from the same pass:

- .nlmixr2saveRestoreParHistType() read the csv with inferred column types, so
  a level like "01" would come back as 1 and "T" as TRUE.  Read as character.

- .nlmixr2saveRestoreIdFactor() applied the ranef levels directly.  ranef
  should cover every subject in the fit table, but union the two rather than
  let a missing one turn into NA while fixing the column's type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourth round of antigravity review on PR #7.

Anchoring the file pattern last commit left the base name interpolated into a
regexp unescaped.  A base name is a variable name or a nlmixr2save.prefix, so
it can hold metacharacters: `my.fit` is an ordinary R name, and as a pattern
its `.` also matches `my_fit`'s files -- which the caller then zips and
unlinks.  `fit+1` fails the other way, matching nothing, leaving a cache that
cannot be loaded.  Match the names literally with startsWith() instead.

The old-cache test asserted the restore script appends an unrecognized type,
but loadFit() repairs an NA type from the csv right afterwards, so the test
passed whether or not the script did its job.  Source the restore script
directly to pin down the script's own behavior, then check loadFit() as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifth round of antigravity review on PR #7.

`.fit` is an ordinary R name and saveFit() takes the base name from the
variable, but list.files() omits dot files unless asked, so saveFit(.fit)
zipped nothing and loadFit() cleaned up nothing.  This predates the PR -- the
regexp the helper replaced had the same hole -- but the helper is where it
belongs.  Pass all.files=TRUE and drop "." / "..".

Also normalize dirname("") to ".": file.path("", x) is rooted at the
filesystem, so a base name with no directory part and no name would have
pointed the zip and the unlink at /.

Adds a fit-free unit test for the helper covering the boundaries that matter:
a longer name starting the same way, the .zip itself, a metacharacter in the
name, a dot name, and a nested directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mattfidler

Copy link
Copy Markdown
Member Author

Antigravity review (Gemini 3.1 Pro), 6 rounds to convergence

Ran agy over the branch diff repeatedly, fixing what survived triage each round, until two passes came back clean. Summary of what was found and what was done. The coverage CI failure is also fixed — it turned out to be a real bug in this package, not the unrelated pre-existing failure the PR description claimed.

Fixed

1. parHistData$type levels were never recorded for compressed fits (the coverage CI failure)

saveFit() read the levels straight out of the fit environment, but nlmixr2est stores parHistData compressed unless control=list(compress=FALSE) — so the env holds a raw vector, is.data.frame() was FALSE, and nothing was recorded. loadFit() then fell back to its hardcoded 9-level list, which nlmixr2est has outgrown ("Analytic Gradient (relaxed)" and friends), and those types came back as NA.

That is exactly the test-coverage failure on this branch and on main — it is not the unrelated failure the PR body describes. Fixed by decompressing with fit$parHistData first, the same way the save loop already handles every other raw item.

2. A cache whose own restore script dropped a type could not be repaired by re-saving

The type levels are applied by the restore script stored inside the zip, so an old cache coerces unknown types to NA on load — and re-saving cannot recover them, because by then the strings are gone. Repaired in loadFit() instead (the same reasoning that put the ID repair there): the -parHistData.csv still holds the original strings and has not been cleaned up yet, so the column is read back and the missing levels appended. Existing caches are fixed in place.

3. saveFit(fit, zip=FALSE) was silently ignored for any fit carrying data

saveFit.nlmixr2FitData() wrote the fit .csv and then called the core method with a hardcoded zip=TRUE. The documented argument did nothing for the common case.

4. Saving or loading one cache could delete another one's files (destructive)

The file list was matched with an unanchored pattern, so a cache named fit also matched myfit-env.RsaveFit() zipped the other cache's files into its own archive and then unlink()ed them. Anchoring alone was not enough: the base name is a variable name or a nlmixr2save.prefix, so it can hold regexp metacharacters — my.fit is an ordinary R name, and as a pattern its . also matches my_fit's files, while fit+1 matches nothing and leaves an unloadable cache. Now matched literally via startsWith(), in one helper instead of three copies.

This was reachable before, but fix 3 makes leftover unzipped files a normal occurrence, so it mattered more.

5. Smaller hardening

  • The repair read the csv with inferred column types, so a level like "01" would come back as 1 and "T" as TRUE. Read as character.
  • .nlmixr2saveRestoreIdFactor() applied the ranef levels directly; now unions them with the table's own IDs so a missing subject can never turn into NA while fixing the column's type.
  • saveFit(.fit) zipped nothing — list.files() omits dot files, and .fit is an ordinary R name. (Predates this PR; the regexp had the same hole.)

Tests

  • The round-trip test could not distinguish ranef-derived ID levels from the appearance-order fallback, because theo_sd's IDs appear in level order. Reversing the recorded levels makes the two disagree and pins down that ranef wins.
  • The old-cache test asserted the restore script appends an unknown type, but loadFit()'s repair fixed it either way, so the test passed whether or not the script worked. It now sources the restore script directly to pin down the script's own behavior, then checks loadFit() separately.
  • New: an old cache with neither ..id.level.. nor ..parHistType.level..; a cache whose own script drops a type; cross-cache file clobbering; and a fit-free unit test for the file matcher covering the metacharacter, dot-name, longer-name and nested-directory boundaries.

Reviewed and rejected

  • read.csv() returning a factor when stringsAsFactors=TRUE — not reachable: read.table() has defaulted to stringsAsFactors=FALSE since R 4.0 regardless of the option, and the option itself is now deprecated.
  • ranef/etaObf being raw in the ID repair — those are restored from read.csv, and the repair only ever runs on loaded fits, so they are never raw there.
  • Degenerate base names (empty string, trailing slash) and case-insensitive-filesystem collisions requiring a hand-made file — not reachable in real use.

Full suite passes locally with NOT_CRAN=true.

🤖 Generated with Claude Code

mattfidler and others added 5 commits August 7, 2026 02:27
The test-coverage job has died twice at ~21-25 min with exit 143 and no test
output at all.  The runner is torn down rather than the step failing, so none
of the always() diagnostic steps run and the partial testthat.Rout is never
uploaded -- there is nothing to diagnose from.

This does not reproduce locally: covr::package_coverage() finishes in 4-6 min
and passes under a warm cache, a cold cache, 4 CPUs, and CI's exact
invocation including install_path.  So the evidence has to come from CI.

Give the step a 20 minute timeout so it fails on its own terms and leaves the
always() steps to run, and add a step that reports memory, disk, cpu and any
kernel OOM messages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diagnostic only.  A controlled experiment settled where the coverage job's
time goes and where it does not: re-running the base commit (4adeb06) today
finished its tests in 3m23s, while this branch exceeds the 20 minute step
timeout on the same runners the same day.  So the cost is on this branch --
but it is not reproducible locally, where the same covr run is only 6-7%
slower than base at 11, 4 and 2 threads alike (3m54 vs 3m41, 6m49 vs 6m23).

Messages go to stderr, so these land in testthat.Rout even when the step is
killed, which pins down which block actually consumes the time on CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An earlier `git add -A` in this branch swept 107 build artifacts into the
repository: a covr/ tree of .gcov files carrying absolute paths from the
machine that ran them, plus the src/*.gcda coverage counters.  None of that
belongs in version control, and the .gcda files are actively harmful -- they
are counters from one build, and CI compiles with --coverage and merges gcov
output on top of whatever .gcda it finds in the tree.

Untrack them, and ignore the whole family in .gitignore so `git add -A` after
a local covr run cannot repeat this.  Also add them to .Rbuildignore so they
stay out of a source tarball if they are sitting in the working tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd trips

Timing markers on CI found where the coverage job's 20 minutes went.  All five
nlmixr2 fits took 81 seconds; three of the tests added in this branch took over
18 minutes between them, each slower than the last:

  a cache saved before the levels...    28s
  saving one fit leaves another cache   2m23s
  restored ID levels come from ranef    4m10s
  a cache whose own script dropped...  >11m28s (killed)

Every extra loadFit rebuilds an rxode2 model.  That is nearly free against a
warm cache, which is why this never showed locally -- the same run was only
6-7% slower than base at 11, 4 and 2 threads alike -- and it compounds against
the cold cache CI starts from, hence the progressive slowdown.

Exercise the two repair functions on hand-built objects instead: a synthetic
environment for the parHistData repair, and a synthetic fit table for the ID
repair.  Neither needs a fit, and both pin the behavior down harder than the
round trips did, because the levels can be made to disagree with the row order
in a way theo_sd's IDs never do.  Keeps the one end-to-end old-cache test.

The file-matching helper already has its own fit-free unit test, which is what
decides which files a save or load touches, so the cross-cache clobber test
goes rather than paying for another round trip to reach the same assertion.

Full covr run now takes 3m20 against the base commit's 3m41, with coverage up
from 82.41% to 82.74%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The resource dump did its job -- it ruled out memory and disk while the real
cause (extra rxode2 model rebuilds in the tests) was tracked down -- and is
just noise now that the job runs in about 6 minutes again.

The step timeout stays.  It is what turned a runner that died silently at 65
minutes into a step that fails and leaves its testthat output behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mattfidler

Copy link
Copy Markdown
Member Author

Coverage CI: fixed, and it was two separate problems

test-coverage is green for the first time since 2026-07-19. It had two causes stacked on top of each other.

1. A real bug in this package (the failure the job was reporting)

nlmixr2est stores parHistData compressed unless control=list(compress=FALSE), so the fit environment holds a raw vector rather than a data frame. saveFit() read the type factor levels straight off that, found no data frame, and recorded nothing — leaving loadFit() to fall back to a hardcoded 9-level list that nlmixr2est has since outgrown. "Analytic Gradient (relaxed)" and friends came back as NA.

That is the test-save.R:464 failure, and it was failing on main too — not the unrelated pre-existing failure the PR description claimed. Fixed by decompressing before reading the levels, the way the save loop already handles every other raw item.

2. Tests I added in this PR were far too expensive on CI

After that fix the job started timing out at 20-25 minutes with no output. It did not reproduce locally: the same covr run was only 6-7% slower than the base commit at 11, 4 and 2 threads alike. A controlled experiment (re-running the base commit the same day: 3m23s) confirmed the cost was on this branch.

Timing markers on CI found it:

block duration
all five nlmixr2 fits 81s
a cache saved before the levels... 28s
saving one fit leaves another cache... 2m23s
restored ID levels come from ranef 4m10s
a cache whose own script dropped... >11m28s (killed)

The fits were never the problem. Each extra loadFit() rebuilds an rxode2 model — nearly free against a warm cache, which is why it was invisible locally, and costly against the cold cache CI starts from, which is why each test was slower than the last.

The repair functions are now exercised on hand-built objects instead: a synthetic environment for the parHistData repair, a synthetic fit table for the ID repair. Neither needs a fit, and both pin the behavior down harder than the round trips did — ranef's levels can be made to disagree with the row order (b,a vs a,b), which theo_sd's IDs never do. One end-to-end old-cache test is kept.

Result: 3m20s locally against the base commit's 3m41s, coverage up from 82.41% to 82.74%. On CI the job now runs in 6 minutes.

Also

  • Removed 107 build artifacts I had accidentally committed here via git add -A — a covr/ tree carrying absolute paths from my machine, plus src/*.gcda coverage counters. The .gcda files are actively harmful: CI compiles with --coverage and merges gcov output on top of whatever it finds in the tree. Now in .gitignore and .Rbuildignore so it cannot recur.
  • Added a 20-minute step timeout. Without it a hung test takes the runner down with it (exit 143, no output) and the always() steps never run, so there is nothing to diagnose from. It is what turned a silent 65-minute death into a step that fails and leaves its testthat output behind.

All checks green: R-CMD-check on all 5 platforms, pkgdown, test-coverage, codecov.

🤖 Generated with Claude Code

Three more files that `git add -A` swept in and the previous cleanup missed:
src/RcppExports.gcno and src/nlmixr2fix.gcno (634KB of gcov graph data, the
.gcno siblings of the .gcda files already removed), and test.csv, a two-row
scratch file left in the repository root by a throwaway experiment and
referenced from nowhere.

Both patterns were already added to .gitignore; being tracked, they needed
removing by hand as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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